Unity Shader入门精要学习笔记 - 第7章 基础纹理

时间:2021-12-08 10:22:58

转自 冯乐乐的 《Unity Shader 入门精要》

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

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

尽管纹理的大小可以是多种多样的,例如可以是256×256或者1028×1028,但顶点UV坐标的范围通常都被归一化到[0,1]范围内。需要注意的是,纹理采样时使用的纹理坐标不一定是在[0,1]范围内。实际上,这种不在[0,1]范围内的纹理坐标有时会非常有用。与之关系紧密的是纹理的平铺模式,它将决定渲染引擎在遇到不在[0,1]范围内的纹理坐标时如何进行纹理采样。

在OpenGL 里,纹理空间的原点位于左下角,而在Directx 中,原点位于左上角。幸运的是,Unity 在绝大多数情况下为我们处理好了这个差异问题,也就是说,即便游戏的目标平台可能既有OpenGL 风格的,也有DirectX 风格的,但我们在Unity 中使用的通常只有一种坐标系。Unity 使用的纹理空间是符合OpenGL 传统的,也就是说,原点位于纹理左下角。如下图

Unity Shader入门精要学习笔记 - 第7章 基础纹理

单张纹理

我们通常会使用一张纹理来代替物体的漫反射颜色。可以得到类似下图中的效果

Unity Shader入门精要学习笔记 - 第7章 基础纹理

在本例中,我们仍然使用Blinn-Phong光照模型来计算光照。

我们新建一个Shader,代码如下:

Shader "Unity Shader Book/Chapter 7/Single Texture"{
Properties{
_Color ("Color Tint", Color) = (1,1,1,1)
_MainTex("MainTex", 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.cgnic"

fixed4 _Color;
sampler2D _MainTex;
//与其他属性类型不同的是,我们还需要为纹理类型的属性声明一个float4类型
//的变量_MainTex_ST。其中,_MainTex_ST的名字不是任意起的。在Unity中,
//我们需要使用纹理名_ST的方式来声明某个纹理的属性。其中ST是缩放和平移的缩写。
//_MainTex_ST可以让我们得到该纹理的缩放和平移值
//_MainTex_ST.xy存储的是缩放值,_MainTex_ST.zw存储的是偏移值
float4 _MainTex_ST;
fixed4 _Specular;
float _Gloss;

//顶点着色器的输入结构体
struct a2v{
float4 vertex : POSITION;
float3 normal : NORMAL;
//unity会将模型的第一组纹理坐标存储到该变量中。
float4 texcoord : TEXCOORD0;
}

//顶点着色器的输出结构体
struct v2f{
float4 pos : SV_POSTION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
//用来存储纹理坐标
float2 uv : TEXCOORD2;
}

//顶点着色器
//在顶点着色器中,我们使用纹理的属性值_MainTex_ST来对顶点纹理坐标进行变换,
//得到最终的纹理坐标。计算过程是,首先使用缩放属性_MainTex_ST.xy对顶点纹理坐标
//进行缩放,然后再使用偏移属性MainTex_ST.zw对结果进行偏移
v2f vert(a2v v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(_Object2World, v.vertex).xyz;
o.uv = v.texcoord.xy * _MainTex_ST.xy + MainTex_ST.zw;
//可以使用内置函数来代替
//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函数对纹理进行采样,第一个参数是需要被采样的纹理,第二个是float2类型的纹理坐标
//我们使用采样结果和颜色属性_Color的乘积来作为材质的反射率albedo
fixed3 albedo = tex2D(_MainTex,i.uv).rgb * _Color.rgb;
//使用albedo来计算漫反射光照的结果
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);
}
}
}
Fallback "Specular"
}

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

Unity Shader入门精要学习笔记 - 第7章 基础纹理

纹理面板中的第一个属性是纹理类型。在本节中,我们使用的是Texture类型,在下面的法线纹理一节中,我们会使用Normal map类型。而在后面的章节中,我们还会看到Cubemap 等高级纹理类型。我们之所以要为导入的纹理选择合适的类型,是因为只有这样才能让Unity 知道我们的意图,为Unity Shader 传递正确的纹理,并在一些情况下可以让Unity 对该纹理进行优化。

当把纹理类型设置为Texture后,下面会有一个Alpha from Grayscale 复选框,如果勾选了它,那么透明通道的值将会由每个像素的灰度值生成。在这里我们不需要勾选它。

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

Unity Shader入门精要学习笔记 - 第7章 基础纹理

需要注意的是,想要纹理得到这样的效果,我们必须使用纹理的属性在Unity Shader 中对顶点纹理坐标进行相应的变换。也就是说,代码中需要包含类似这样的代码:

o.uv = u.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
// Or just call the built-in function
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
我们还可以在材质面板中调整纹理的偏移量,下图给出了两种模式下调整纹理偏移量的一个例子。

Unity Shader入门精要学习笔记 - 第7章 基础纹理
上图展示了再纹理的偏移属性为(0.2,0.6)时分别使用两种Wrap Mode 的结果。

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

Unity Shader入门精要学习笔记 - 第7章 基础纹理

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

Unity Shader入门精要学习笔记 - 第7章 基础纹理

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

Unity Shader入门精要学习笔记 - 第7章 基础纹理

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

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

Unity Shader入门精要学习笔记 - 第7章 基础纹理

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

而Format 决定了Unity 内部使用哪种格式来存储该纹理。如果我们将Texture Type 设置为 Advanced,那么会有更多的Format 供我们选择。需要知道的是,使用的纹理格式精度越高,占用的内存空间越大,但得到的效果也越好。我们可以从纹理导入面板的最下方看到存储该纹理需要占用的内存空间(如果开启了多级渐远纹理技术,也会增加纹理的内存占用)。当游戏使用了大量的Truecolor 类型的纹理时,内存可能会迅速增加,因此对于一些不需要使用很高精度的纹理(例如用于漫反射颜色的纹理),我们应该尽量使用压缩格式。


 凹凸映射

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

有两种主要的方法可以用来进行凹凸映射:一种方法是使用一张高度纹理来模拟表面位移,然后得到一个修改后的法线值,这种方法也被称为高度映射;另外一种方法则是使用一张法线纹理来直接存储表面法线,这种方法又被称为法线映射。尽管我们常常将凹凸映射和法线映射当成是相同的技术,但读者需要知道它们之间的不同。

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

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

Unity Shader入门精要学习笔记 - 第7章 基础纹理


而法线纹理中存储的就是表面的法线方向。由于法线放的分量范围在[-1, 1],而像素的分量范围为[0,1],因此,我们需要做一个映射,通常使用的映射就是 pixel = (normal + 1) / 2

这就要求,我们在Shader中对法线纹理进行纹理采样后,还需要对结果进行因此反映射的过程,以得到原先的法线方向。反映射的过程实际就是使用上面映射函数的逆函数:normal = pixel×2 - 1

然而,由于方向是对相对于坐标空间来说的,那么法线纹理中存储的法线方向在哪个坐标空间中呢?对于模型顶点自带的法线,它们是定义在模型空间中的,因此一种直接的想法就是将修改后的模型空间中的表面法线存储在一张纹理中,这种纹理被称为是模型空间的法线纹理。然而,在实际制作中,我们往往会采用另一种坐标空间,即模型顶点的切线空间来存储法线。对于模型的每个顶点,它都有一个属于自己的切线空间,这个切线空间的原点就是该顶点本身,而z轴是顶点的法线方向(n),x轴是顶点的切线方向(t),而y轴可由法线和切线叉积而得,也被称为是副切线或副法线,如下图所示。

Unity Shader入门精要学习笔记 - 第7章 基础纹理

这种纹理被称为是切线空间的法线纹理。下图给出了模型空间和切线空间下的法线纹理。

Unity Shader入门精要学习笔记 - 第7章 基础纹理

从上图可以看出,模型空间下的法线纹理看起来是“五颜六色”的。这是因为所有法线所在的坐标空间是同一个坐标空间,即模型空间,而每个点存储的法线方向是各异的,有的是(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进行采样。如果同时要进行法线映射,我们就需要把法线方法变换到世界空间下。

1、在切线空间下计算

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

为此,我们写了一个新的Shader

Shader "Unity Shaders Book/Chapter 7/Normal Map In Tangent Space"{
Properties{
_Color ("Color Tint", Color) = (1,1,1,1)
_MainTex("MainTex", 2D) = "white" {}
//法线纹理,使用"bump"作为默认值。
//"bump"是Unity内置的法线纹理,当没有提供任何法线纹理时,"bump"就对应了模型自带的法线信息。
_BumpMap("Normal Map", 2D) = "bump" {}
//_BumpScale是用于控制凹凸程度,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.cgnic"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Spcular;
float _Gloss;

//顶点着色器输入结构体
struct a2v{
float4 vertex : POSTION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};

//顶点着色器的输出结构
struct v2f{
float4 pos : SV_POSTION;
float4 uv : TEXCOORD0;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
};

//由于我们使用了两张纹理,因此需要存储两个纹理坐标。为此,我们把v2f中的uv变量
//的类型定义为float4,其中xy分量存储了_MainTex的纹理坐标,而zw分量存储了_BumpMap
//的纹理坐标。然后,我们把模型空间下的切线方向、副切线方向和法线方向按行排列来得到
//从模型空间到切线空间的变换矩阵 rotation。需要注意的是,在计算副切线时我们使用
//v.tangent.w 和叉积结果进行相乘,这是因为和切线与法线方向都垂直的方向有两个,而w
//决定了我们选择其中哪一个方向。
v2f vert(a2v v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, 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 binormal = cross(normalize(v.normal),normalize(v.tangent.xyz)) * v.tangent.w;
//float3×3 rotation = float3×3(v.tangent.xyz, binormal,v.normal);
//以上代码可以用 TANGENT_SPACE_ROTATION 代替

TANGENT_SPACE_ROTATION;

//我们使用内置函数ObjSpaceLightDir 和 ObjSpaceViewDir 来得到模型空间下
//的光照和视角方向,再利用变换矩阵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);
//先利用tex2D对法线纹理_BumpMap 进行采样
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);
//利用_BumpScale 控制凹凸程度
tangentNormal.xy *= _BumpScale;
//由于法线都是单位矢量,因此tangentNormal.z 可以由tangentNormal.xy计算而得
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"
}

下图给出了不同的BumpScale 属性值下得到的结果

Unity Shader入门精要学习笔记 - 第7章 基础纹理


现在,我们来实现第二种方法,即在世界空间下计算光照模型。我们需要在片元着色器中把法线方向从切线空间变换到世界空间下。这种方法的基本思想是:在顶点着色器中计算从切线空间到世界空间的变换矩阵,并把它传递给片元着色器。变换矩阵的计算可以由顶点的切线、副切线和法线在世界空间下的表示来得到。最后,我们只需要在片元着色中把法线纹理中的法线方向从切线空间变换到世界空间下即可。尽管这种方法需要更多的计算,但在需要使用Cubemap进行环境映射等情况下,我们就需要使用这种方法。

为此,我们写一个新的Shader

Shader "Unity Shaders Book/Chapter 7/Normal Map In World Space"{
Properties{
_Color ("Color Tint", Color) = (1,1,1,1)
_MainTex("MainTex", 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.cgnic"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Spcular;
float _Gloss;

//顶点着色器输入结构体
struct a2v{
float4 pos : POSTION;
float4 uv : TEXCOORD0;
float4 TtoW0 : TEXCOORD1;
float4 TtoW1 : TEXCOORD2;
float4 TtoW2 : TEXCOORD3;
};

//顶点着色器的输出结构
struct v2f{
float4 pos : SV_POSTION;
float4 uv : TEXCOORD0;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
};


v2f vert(a2v v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, 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(_Object2World,v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
fixed3 worldTangent = UnityObejctToWorldDir(v.tangent.xyz);
fixed3 worldBinormal = cross(worldNormal,worldTangent) * v.tangent.w;

//然后把上述矩阵的每一行分别存储在TtoW0、TtoW1、TtoW2中,并把世界空间下
//的顶点位置的xyz分量分别存储再了这些变量的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));

fixed3 bump = UnpackNormal(tex2D(_Bump,i.uv.zw));
bump.xy *= _BumpScale;
bump.z = sqrt(1.0 - saturate(dot(bump.xy,bump.xy)));
bump = normalize(half3(dot(i.TtoW0.xyz,bump),dot(.TtoW1.xyz,bump),dot(.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"
}

上面我们提到了当把法线纹理的纹理类型标识成Normalmap时,可以使用Unity的内置函数UnpackNormal 来得到正确的法线方向,如下图所示:


当我们需要使用那些包含了法线映射的内置的Unity Shader 时,必须把使用的法线纹理按上面的方式标识成Normal map 才能得到正确的结果(即便你忘了这么做,Unity 也会在材质面板中提醒你修正这个问题),这是因为这些Unity Shader 都使用了内置的UnpackNormal 函数来采样法线方向。

简单来说,这么做可以让Unity 根据不同平台对纹理进行压缩,再通过 UnpackNormal 函数来针对不同的压缩格式对法线纹理进行正确的采样。我们可以在 UnityCG.cgnic 里找到 UnpackNormal 函数的内部实现:

inline fixed3 UnpackNormalDXT5nm(fixed4 packednormal)
{
fixed3 normal;
normal.xy = packednormal.wy * 2 - 1;
normal.z = sqrt(1 - saturate(dot(normal.xy,normal,xy)));
return normal;
}

inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if defined(UNITY_NO_DXT5nm)
return packednormal.xyz * 2 - 1;
#else
return UnpackNormalDXT5nm(packednormal);
#endif
}
从代码中可以看出,在某些平台上由于使用了DXT5nm的压缩格式,因此需要针对这种格式对法线进行解码。在DXT5nm格式的法线纹理中,纹素的a通道(即w分量)对应了法线的x分量,g通道对应了法线的y分量,而纹理的r和b通道则会被舍弃,法线的z分量可以由xy分量推导而得。为什么之前的普通纹理不能按这种方式压缩,而法线多久需要使用DXT5nm格式来进行压缩呢?这是因为,按我们之前的处理方式,法线纹理被当成一个和普通纹理无异的图,但实际上,它只有两个通道是真正必不可少的,因为第三个通道的值可以用另外两个推导出来(法线是单位向量,并且切线空间下的法线方向的z分量始终为正)。使用这种压缩方法就可以减少法线纹理占用的内存空间。

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

Unity Shader入门精要学习笔记 - 第7章 基础纹理

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


渐变纹理

Unity Shader入门精要学习笔记 - 第7章 基础纹理

由上图可以看出,使用渐变纹理的方式可以*地控制物体的漫反射光照。不同的渐变纹理有不同的特性。例如,在坐标的图中,我们使用一张从紫色调到浅黄色调的渐变纹理。而中间的图使用的渐变纹理是从黑色逐渐向浅灰色靠拢,而且中间的分界线部分微微发红,这是因为画家在插图中往往会在阴影处使用这样是色调;右侧的渐变纹理则被用于卡通风格的渲染,这种渐变纹理中的色调通常是突变的,即没有平滑过渡,以此来模拟卡通中的阴影色块。

我们写一个新的Shader来模拟实现

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{
Tags {"LightMode" = "ForwardBase"}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "Lighting.cgnic"

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_POSTION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
};

v2f vert(a2v v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(_Object2World, v.vertex).xyz;
//使用内置的TRANSFORM_TEX宏来计算经过平铺和偏移后的纹理坐标
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;
//使用halfLambert来构建一个纹理坐标,并用这个纹理坐标对渐变纹理_RampTex进行采样
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"
}

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

Unity Shader入门精要学习笔记 - 第7章 基础纹理

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


遮罩纹理

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

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

下图显示了只包含漫反射、未使用遮罩的高光反射和使用遮罩的高光反射的对比结果:

Unity Shader入门精要学习笔记 - 第7章 基础纹理

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

Unity Shader入门精要学习笔记 - 第7章 基础纹理

我们新建一个Shader 来实现上述效果。

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
//我们需要使用的高光反射遮罩纹理
_SpecularMark("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.cgnic"

fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float _BumpScale;
sampler2D _SpecularMark;
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_POSTION;
float2 uv : TEXCOORD0;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
};

v2f vert(a2v v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP,v.vertex);
o.uv.xy = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;

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);
//在计算高光反射时,我们首先对遮罩纹理_SpecularMark进行采样
//由于本例使用的遮罩纹理中每个纹素的rgb分量其实都是一样的,
//表面了该点对应的高光反射强度,在这里我们选择使用r分量来计算掩码值
fixed specularMask = tex2D(_SpecularMark,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);
}

}
Fallback "Specular"

}

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