纹理最初的目的就是使用一张图片来控制模型的外观。使用纹理映射( texture mapping )技术,我们可以把一张图“黏”在模型表面,逐纹素(texel) (纹素的名字是为了和像素进行区分〉地控制模型的颜色。
在美术人员建模的时候,通常会在建模软件中利用纹理展开技术把纹理映射坐标( texture-mapping coordinates ) 存储在每个顶点上。纹理映射坐标定义了该顶点在纹理中对应的 2D 坐标。通常,这些坐标使用一个二维变量(u, v)来表示,其中u 是横向坐标,而v 是纵向坐标。因此,纹理映射坐标也被称为UV 坐标。
尽管纹理的大小可以是多种多样的,例如可以是256 x256 或者1028 x 1028,但顶点UV 坐标的范围通常都被归一化到[0,,1]范围内。需要注意的是,纹理采样时使用的纹理坐标不一定是在[0, 1]范围内。实际上,这种不在[0, 1 ]范围内的纹理坐标有时会非常有用。与之关系紧密的是纹理的平铺模式,它将决定渲染引擎在遇到不在[0, 1 ]范围内的纹理坐标时如何进行纹理来样。我们将在 7.1.2 节中更加详细地进行阐述。
在本书之前的章节中,我们曾不止一次地提到过OpenGL 和DirectX 在二维纹理空间中的坐标系差异问题。重要的事情要说很多次,我们再来回顾一下。在OpenGL 里,纹理空间的原点位于左下角,而在DirectX 中, 原点位于左上角。幸运的是, Unity 在绝大多数情况下(特例情况可以参见5.6 节)为我们处理好了这个差异问题,也就是说,即便游戏的目标平台可能既有OpenGL 风格的,也有DirectX 风格的,但我们在Unity 中使用的通常只有一种坐标系。Unjty使用的纹理空间是符合OpenGL 的传统的,也就是说,原点位于纹理左下角,如图7.1 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/41ed3d2bfa3f17ed9efab17ef9d505c3.png)
本章将介绍如何在Unity 中利用纹理采样来实现更加丰富的视觉效果。
在7.1 节中,我们将学习如何在Unity Shader 中进行最基本的纹理采样,并介绍纹理的属性等基本概念。
7.2 节将介绍游戏中应用广泛的凹凸纹理, 还会解释Unity 中法线纹理的一些实现细节。
7.3 节和7.4 节将分别介绍两类特殊的纹理类型,即渐变纹理和遮罩纹理,这些纹理在游戏中的应用非常广泛。
需要提醒读者注意的是,本章着重讲述纹理采样的原理,因此实现的Shader 往往并不能直接应用到实际项目中(直接使用的话会缺少阴影、光照衰减等效果)。我们会在9.5 节给出包含了纹理采样和完整光照模型的可真正使用的Unity Shader.
7.1 单张纹理
我们通常会使用一张纹理来代替物体的漫反射颜色。在本节中,我们将学习如何在Unity Shader 中使用单张纹理来作为模拟的颜色。在学习完本节后, 我们会得到类似图7.2 中的效果。
7.1.1 实践
在本例中, 我们仍然使用Blinn-Phong 光照模型来计算光照。准备工作如下。(1)在Unity 中新建一个场景。在本书资源中,该场景名为Scene_7_1 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window ->Lighting -> Skybox 中去掉场景中的天空盒子。
(2)新建一个材质。在本书资源中,该材质名为SingleTextureMat。
(3)新建一个Unity Shader。在本书资源中,该Unity Shader 名为Chapter7-SingleTexture . 把新的Unity Shader 赋给第2 步中创建的材质。
(4) 在场景中创建一个胶囊体, 并把第2 步中的材质赋给该胶囊体。
(5)保存场景。
打开新建的Chapter7-SingleTexture, 删除所有已有代码,并进行如下修改。
( 1 )首先,我们需要为这个Unity Shader 起一个名字:
Shader "Unity Shaders Book/Chapter 7/Single Texture" {
( 2)为了使用纹理,我们需要在Properties 语义块中添加一个纹理属性:
Properties {
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_Specular ("Specular", Color) = (1, 1, 1, 1)
_Gloss ("Gloss", Range(8.0, 256)) = 20
}
上面的代码声明了一个名为 _MainTex 的纹理,在3.3.2 节中,我们已经知道2D 是纹理属性的声明方式。我们使用一个字符串后跟一个花括号作为它的初始值,“white" 是内置纹理的名字,也就是一个全白的纹理。为了控制物体的整体色调,我们还声明了一个Color 属性。
(3)然后,我们在SubShader 语义块中定义了一个Pass 语义块。而且,我们在Pass 的第一行指明了该Pass 的光照模式:
SubShader {
Pass {
Tags { "LightMode"="ForwardBase" }
LightMode 标签是Pass 标签中的一种,它用于定义该Pass 在Unity 的光照流水线中的角色。
( 4)接着,我们使用CGPROGRAM 和 ENDCG 来包围住CG 代码片,以定义最重要的顶点着色器和片元着色器代码。首先,我们使用#pragma 指令来告诉Unity,我们定义的顶点着色器和片元着色器叫什么名字。在本例中,它们的名字分别是vert 和frag:
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
( 5 )为了使用Unity 内置的一些变量,如 _LightColor0 ,还需要包含进Unity 的内置文件 Lighting.cginc :
#include "Lighting.cginc"
(6)我们需要在CG 代码片中声明和上述属性类型相匹配的变量,以便和材质面板中的属性建立联系:
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Specular;
float _Gloss;
与其他属性类型不同的是,我们还需要为纹理类型的属性声明一个 float4 类型的变量 _MainTex_ST。其中, _MainTex_ST 的名字不是任意起的。在Unity 中,我们需要使用 纹理名_ST 的方式来声明某个纹理的属性。其中, ST 是缩放( scale )和平移
(translation )的缩写。 _MainTex_ST 可以让我们得到该纹理的缩放和平移〈偏移)值,_MainTex_ST.xy 存储的是缩放值,而
_MainTex ST.zw 存储的是偏移值。这些值可以在材质面板的纹理属性中调节,如图7.3 所示。在7.1.2 节中,我们将更详细地解释这些纹理属性。
![](https://i-blog.csdnimg.cn/blog_migrate/c39a95070d33361700a4d6cb8d906d12.png)
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;
};
在上面的代码中,我们首先在a2v 结构体中使用TEXCOORD0 语义声明了一个新的变量texcoord , 这样Unity 就会将模型的第一组纹理坐标存储到该变量中。然后,我们在v2f 结构体中添加了用于存储纹理坐标的变量 uv, 以便在片元着色器中使用该坐标进行纹理采样。
( 8 )然后,我们定义了顶点着色器:
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;
// Or just call the built-in function
// o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
return o;
}
在顶点着色器中,我们使用纹理的属性值 _MainTex_ST 来对顶点纹理坐标进行变换,得到最终的纹理坐标。计算过程是,首先使用缩放属性 _MainTex_ST.xy 对顶点纹理坐标进行缩放,然后再使用偏移属性 _MainTex_ST.zw 对结果进行偏移。Unity 提供了一个内置宏 TRANSFORM_TEX 来帮我们计算上述过程。TRANSFORM_TEX 是在UnityCG.cginc 中定义的:
// Transforms 2D UV by scale/bias propperty
#define TRANSFORM_TEX(tex,name) (tex.xy * name##_ST.xy + name##_ST.zw)
它接受两个参数,第一个参数是顶点纹理坐标,第二个参数是纹理名,在它的实现中,将利用纹理名 _ST 的方式来计算变换后的纹理坐标。
( 9 )我们还需要实现片元着色器,并在计算漫反射时使用纹理中的纹素值:
fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
// Use the texture to sample the diffuse 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);
}
上面的代码首先计算了世界空间下的法线方向和光照方向。然后,使用CG 的 tex2D 函数对纹理进行采样。它的第一个参数是需要被采样的纹理,第二个参数是一个float2 类型的纹理坐标,它将返回计算得到的纹素值。我们使用采样结果和颜色属性 _Color 的乘积来作为材质的反射率 albedo,并把它和环境光照相乘得到环境光部分。随后,我们使用 albedo 来计算漫反射光照的结果,并和环境光照、高光反射光照相加后返回。
( 10)最后,我们为该Shader 设置了合适的Fallback:
FallBack "Specular"
保存后返回Unity 中查看。在SingleTextureMat 的面板上,我们使用本书资源中的 Brick_Diffuse.jpg 纹理对Main_Tex 属性进行赋值。
7.1.2 纹理的属性
虽然很多资料把Unity 的纹理映射描述得很简单一一声明一个纹理变量,再使用tex2D 函数采样。实际上,在渲染流水线中,纹理映射的实现远比我们想象的复杂。在本书不会过多涉及一些具体的实现细节,但要解释一些我们认为读者必须要知道的事情。在本节中,我们将关注Unity中的纹理属性。
在我们向Unity 中导入一张纹理资源后,可以在它的材质面板上调整其属性,如图7.4 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/0550afaf068ac73f004595a99b10d31f.png)
当把纹理类型设置成Texture 后,下面会有一个Alpha from Grayscale 复选框,如果勾选了它,那么透明通道的值将会由每个像素的灰度值生成。关于透明效果,我们会在第8 章中讲到。在这里我们不需要勾选它。
下面一个属性非常重要一一Wrap Mode。它决定了当纹理坐标超过[0, 1]范围后将会如何被平铺。Wrap Mode 有两种模式: 一种是Repeat,在这种模式下,如果纹理坐标超过了1 ,那么它的整数部分将会被舍弃,而直接使用小数部分进行采样,这样的结果是纹理将会不断重复; 另一种是Clamp , 在这种模式下,如果纹理坐标大于1 ,那么将会截取到1,如果小于0 ,那么将会截取到0. 图7.5 给出了两种模式下平铺一张纹理的效果(读者可在本书资源中的Scene_7_1_2_a 中找到相应场景〉。
![](https://i-blog.csdnimg.cn/blog_migrate/d84f3e4e813c0d4754eff156a08a0271.png)
需要注意的是,想要让纹理得到这样的效果,我们必须使用纹理的属性(例如上面的 _MainTex_ST 变量)在Unity Shader 中对顶点纹理坐标进行相应的变换。也就是说,代码中需要包含类似下面的代码:
o.uv = v.texcoord.xy * _MainTex_ST.xy + _MainTex_ST.zw;
// Or just call the built-in function
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
我们还可以在材质面板中调整纹理的偏移量,图7.6 给出了两种模式下调整纹理偏移量的一个例子。
![](https://i-blog.csdnimg.cn/blog_migrate/b9c11e5188a73a2900cf4b52d448fae5.png)
纹理缩小的过程比放大更加复杂一些,此时原纹理中的多个像素将会对应一个目标像素。纹理缩放更加复杂的原因在于我们往往需要处理抗锯齿问题, 一个最常使用的方法就是使用多级渐远纹理( mipmapping) 技术。其中“mip ” 是拉丁文"multum in parvo”的缩写,它的意思是“在一个小空间中有许多东西”。如同它的名字,多级渐远纹理技术将原纹理提前用滤波处理来得到很多更小的图像, 形成了一个图像金字塔,每一层都是对上一层图像降采样的结果。这样在实时运行时,就可以快速得到结果像素,例如当物体远离摄像机时,可以直接使用较小的纹理。但缺点是需要使用一定的空间用于存储这些多级渐远纹理,通常会多占用33%的内存空间。这是一种典型的用空间换取时间的方法。在Unity 中,我们可以在纹理导入面板中,首先将纹理类型(Texture Type )选择成Advanced,再勾选Generate MipMaps 即可开启多级渐远纹理技术。同时,我们还可以选择生成多级渐远纹理时是否使用线性空间(用于伽玛校正,详见18.4.2 节〉以及采用的滤波器等,如图7.8 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/f8635d8dcf5188a3a722df8c50aaa6cf.png)
在内部实现上, Point 模式使用了最近邻( nearest neighbor ) 滤波,在放大或缩小时,它的采样像素数目通常只有一个,因此图像会看起来有种像素风格的效果。而Bilinear 滤波则使用了线性滤波,对于每个目标像素,它会找到4 个邻近像素,然后对它们进行线性插值混合后得到最终像素,因此图像看起来像被模糊了。而Trilinear 滤波几乎是和Bilinear 一样的,只是Trilinear还会在多级渐远纹理之间进行混合。如果一张纹理没有使用多级渐远纹理技术,那么Trilinear 得到的结果是和Bilinear 就一样的。通常,我们会选择Bilinear 滤波模式。需要注意的是,有时我们不希望纹理看起来是模糊的,例如对于一些类似棋盘的纹理,我们希望它就是像素风的,这时我们可能会选择Point 模式。
最后,我们来讲一下纹理的最大尺寸和纹理模式。当我们在为不同平台发布游戏时,需要考虑目标平台的纹理尺寸和质量问题。Unity 允许我们为不同目标平台选择不同的分辨率,如图7.10 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/8732845925536ca85c4ce9c898042c3e.png)
而Format 决定了Unity 内部使用哪种格式来存储该纹理。如果我们将Texture Type 设置为Advanced ,那么会有更多的Format供我们选择。这里不再依次介绍每种纹理模式,但需要知道的是,使用的纹理格式精度越高(例如使用Truecolor),占用的内存空间越大,但得到的效果也越好。我们可以从纹理导入面板的最下方看到存储该纹理需要占用的内存空间〈如果开启了多级渐远纹理技术, 也会增加纹理的内存占用) 。当游戏使用了大量Truecolor 类型的纹理时,内存可能会迅速增加,因此对于一些不需要使用很高精度的纹理(例如用于漫反射颜色的纹理〉,我们应该尽量使用压缩格式。
7.2 凹凸映射
纹理的另一种常见的应用就是凹凸映射(bump mapping )。凹凸映射的目的是使用一张纹理来修改模型表面的法线,以便为模型提供更多的细节。这种方法不会真的改变模型的顶点位置,只是让模型看起来好像是“凹凸不平”的,但可以从模型的轮廓处看出“破绽”。
有两种主要的方法可以用来进行凹凸映射: 一种方法是使用一张高度纹理( height map ) 来模拟表面位移( displacement ), 然后得到一个修改后的法线值,这种方法也被称为高度映射( height mapping ); 另一种方法则是使用一张法线纹理(normal map )来直接存储表面法线,这种方法又被称为法线映射( normal mapping )。尽管我们常常将凹凸映射和法线映射当成是相同的技术,但读者需要知道它们之间的不同。
7.2.1 高度纹理
我们首先来看第一种技术,即使用一张高度图来实现凹凸映射。高度图中存储的是强度值( intensity ),它用于表示模型表面局部的海拔高度。因此,颜色越浅表明该位置的表面越向外凸起,而颜色越深表明该位置越向里凹。这种方法的好处是非常直观,我们可以从高度图中明确地知道一个模型表面的凹凸情况,但缺点是计算更加复杂,在实时计算时不能直接得到表面法线,而是需要由像素的灰度值计算而得,因此需要消耗更多的性能。图7.11 给出了一张高度图。![](https://i-blog.csdnimg.cn/blog_migrate/da7fb69b5f4b98f9cf424f82746150a1.png)
7.2.2 法线纹理
而法线纹理中存储的就是表面的法线方向。由于法线方向的分量范围在[-1, 1 ],而像素的分量范围为[0, 1],因此我们需要做一个映射,通常使用的映射就是:这就要求,我们在Shader 中对法线纹理进行纹理来样后,还需要对结果进行一次反映射的过程,以得到原先的法线方向。反映射的过程实际就是使用上面映射函数的逆函数:
然而,由于方向是相对于坐标空间来说的,那么法线纹理中存储的法线方向在哪个坐标空间中呢?对于模型顶点自带的法线,它们是定义在模型空间中的,因此一种直接的想法就是将修改后的模型空间中的表面法线存储在一张纹理中,这种纹理被称为是模型空间的法线纹理( object-space normal map )。然而,在实际制作中,我们往往会采用另一种坐标空间,即模型顶点的切线空间
( tangent space )来存储法线。对于模型的每个顶点,它都有一个属于自己的切线空间,这个切线空间的原点就是该顶点本身,而z 轴是顶点的法线方向(n), x 轴是顶点的切线方向(t),而y 轴可由法线和切线叉积而得,也被称为是副切线( bitangent, b )或副法线,如图7.12 所示。
![](https://i-blog.csdnimg.cn/blog_migrate/48b8c8b8cbf7fbc3dd902527acede5f5.png)
![](https://i-blog.csdnimg.cn/blog_migrate/2e45d563a1ccbc4accbd0f2e15c8174b.png)
而切线空间下的法线纹理看起来几乎全部是浅蓝色的。这是因为,每个法线方向所在的坐标空间是不一样的,即是表面每点各自的切线空间。这种法线纹理其实就是存储了每个点在各自的切线空间中的法线扰动方向。也就是说,如果一个点的法线方向不变,那么在它的切线空间中, 新的法线方向就是z 轴方向,即值为(0, 0, 1 ) , 经过映射后存储在纹理中就对应了RGB(0.5, 0.5, 1 )浅蓝色。而这个颜色就是法线纹理中大片的蓝色。这些蓝色实际上说明顶点的大部分法线是和模型本身法线一样的,不需要改变。
总体来说,模型空间下的法线纹理更符合人类的直观认识,而且法线纹理本身也很直观, 容易调整,因为不同的法线方向就代表了不同的颜色。但美术人员往往更喜欢使用切线空间下的法线纹理。那么,为什么他们更偏好使用这个看起来“很鳖脚”的切线空间呢?
实际上,法线本身存储在哪个坐标系中都是可以的, 我们甚至可以选择存储在世界空间下。
但问题是,我们并不是单纯地想要得到法线,后续的光照计算才是我们的目的。而选择哪个坐标系意味着我们需要把不同信息转换到相应的坐标系中。例如,如果选择了切线空间, 我们需要把从法线纹理中得到的法线方向从切线空间转换到世界空间(或其他空间)中。
总体来说,使用模型空间来存储法线的优点如下。
- 实现简单, 更加直观。我们甚至都不需要模型原始的法线和切线等信息, 也就是说,计算更少。生成它也非常简单, 而如果要生成切线空间下的法线纹理,由于模型的切线一般是和UV 方向相同,因此想要得到效果比较好的法线映射就要求纹理映射也是连续的。
- 在纹理坐标的缝合处和尖锐的边角部分,可见的突变(缝隙)较少,即可以提供平滑的边界。这是因为模型空间下的法线纹理存储的是同一坐标系下的法线信息, 因此在边界处通过插值得到的法线可以平滑变换。而切线空间下的法线纹理中的法线信息是依靠纹理坐标的方向得到的结果,可能会在边缘处或尖锐的部分造成更多可见的缝合迹象。
- 自由度很高。 模型空间下的法线纹理记录的是绝对法线信息,仅可用于创建它时的那个模型,而应用到其他模型上效果就完全错误了。而切线空间下的法线纹理记录的是相对法线信息,这意味着,即便把该纹理应用到一个完全不同的网格上,也可以得到一个合理的结果。
- 可进行UV 动画。比如,我们可以移动一个纹理的UV 坐标来实现一个凹凸移动的效果,但使用模型空间下的法线纹理会得到完全错误的结果。原因同上。这种UV 动画在水或者火山熔岩这种类型的物体上会经常用到。
- 可以重用法线纹理。比如, 一个砖块,我们仅使用一张法线纹理就可以用到所有的6 个面上。原因同上。
- 可压缩。由于切线空间下的法线纹理中法线的Z 方向总是正方向,因此我们可以仅存储XY 方向,而推导得到Z 方向。而模型空间下的法线纹理由于每个方向都是可能的, 因此必须存储3 个方向的值,不可压缩。
切线空间下的法线纹理的前两个优点足以让很多人放弃模型空间下的法线纹理而选择它。从上面的优点可以看出,切线空间在很多情况下都优于模型空间,而且可以节省美术人员的工作。因此,在本书中,我们使用的也是切线空间下的法线纹理。
7.2.3 实践
我们需要在计算光照模型中统一各个方向矢量所在的坐标空间。由于法线纹理中存储的法线是切线空间下的方向, 因此我们通常有两种选择: 一种选择是在切线空间下进行光照计算,此时我们需要把光照方向、视角方向变换到切线空间下: 另一种选择是在世界空间下进行光照计算,此时我们需要把采样得到的法线方向变换到世界空间下,再和世界空间下的光照方向和视角方向进行计算。从效率上来说,第一种方法往往要优于第二种方法,因为我们可以在顶点着色器中就完成对光照方向和视角方向的变换, 而第二种方法由于要先对法线纹理进行采样, 所以变换过程必须在片元着色器中实现, 这意味着我们需要在片元着色器中进行一次矩阵操作。但从通用性角度来说,第二种方法要优于第一种方法, 因为有时我们需要在世界空间下进行一些计算, 例如在使用Cubemap 进行环境映射时, 我们需要使用世界空间下的反射方向对Cubemap 进行采样。如果同时需要进行法线映射, 我们就需要把法线方向变换到世界空间下。当然,读者可以选择其他坐标空间进行计算,例如模型空间等, 但切线空间和世界空间是最为常用的两种空间。在本节中,我们将依次实现上述的两种方法。1. 在切线空间下计算
我们首先来实现第一种方法, 即在切线空间下计算光照模型。基本思路是: 在片元着色器中通过纹理采样得到切线空间下的法线, 然后再与切线空间下的视角方向、光照方向等进行计算,得到最终的光照结果。为此, 我们首先需要在顶点着色器中把视角方向和光照方向从模型空间变换到切线空间中, 即我们需要知道从模型空间到切线空间的变换矩阵。这个变换矩阵的逆矩阵,即从切线空间到模型空间的变换矩阵是非常容易求得的,我们在顶点着色器中按切线( x 轴)、副切线(y 轴〉、法线( z 轴) 的顺序按列排列即可得到(数学原理详见4.6.2 节〉。在4.6 .2 节中我们已经知道, 如果一个变换中仅存在平移和旋转变换,那么这个变换的逆矩阵就等于它的转置矩阵,而从切线空间到模型空间的变换正是符合这样要求的变换。因此,从模型空间到切线空间的变换矩阵就是从切线空间到模型空间的变换矩阵的转置矩阵,我们把切线(x 轴)、副切线(y 轴)、法线( z 轴〉的顺序按行排列即可得到。在本节最后, 我们可以得到类似图7.14 中的效果。
为此,我们进行如下准备工作。
(1)在Unity 中新建一个场景。在本书资源中, 该场景名为Scene_7_2_3 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window ->Lighting -> Skybox 中去掉场景中的天空盒子。
( 2)新建一个材质。在本书资源巾,该材质名为NonnalMapTangentSpaceMat .
( 3 )新建一个Unity Shader。在本书资源中,该Unity Shader 名为Cbapter7-NormalMapTangentSpace。把新的Unity Shader 赋给第2 步中创建的材质。
(4)在场景中创建一个胶囊体,并把第2 步中的材质赋给该胶囊体。
(5)保存场景。
打开新建的Chapter7-NormalMapTangentSpace,删除所有已有代码,并进行如下修改。
#include "Lighting.cginc"
( 6 )为了和Properties 语义块中的属性建立联系,我们在CG 代码块中声明了和上述属性类型匹配的变量:
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Specular;
float _Gloss;
为了得到该纹理的属性(平铺和偏移系数〉,我们为 _MainTex 和 _BumpMap 定义了 _MainTex_ST 和 _BwnpMap_ST 变量。
(7)我们已经知道,切线空间是由顶点法线和切线构建出的一个坐标空间,因此我们需要得到顶点的切线信息。为此,我们修改顶点着色器的输入结构体a2v:
struct a2v {
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};
我们使用TANGENT 语义来描述float4 类型的tangent 变量,以告诉Unity 把顶点的切线方向填充到tangent 变量中。需要注意的是,和法线方向normal 不同, tangent 的类型是float4,而非float3, 这是因为我们需要使用tangent.w 分量来决定切线空间中的第三个坐标轴一一副切线的方向性。
( 8 ) 我们需要在顶点着色器中计算切线空间下的光照和视角方向, 因此我们在v2f 结构体中添加了两个变量来存储变换后的光照和视角方向:
struct v2f {
float4 pos : SV_POSITION;
float4 uv : TEXCOORD0;
float3 lightDir: TEXCOORD1;
float3 viewDir : TEXCOORD2;
};
( 9 ) 定义顶点着色器:
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;
// 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 built-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;
}
由于我们使用了两张纹理,因此需要存储两个纹理坐标。为此,我们把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 把它们从模型空间变换到切线空间中。
(10)由于我们在顶点着色器中完成了大部分工作,因此片元着色器中只需要来样得到切线空间下的法线方向,再在切线空间下进行光照计算即可:
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 funciton
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);
}
在上面的代码中,我们首先利用tex2D 对法线纹理 _BumpMap 进行采样。正如本节一开头所讲的,法线纹理中存储的是把法线经过映射后得到的像素值, 因此我们需要把它们反映射回来。
如果我们没有在Unity 里把该法线纹理的类型设置成Normal map (详见7.2.4 节〉,就需要在代码中手动进行这个过程。我们首先把packedNormal 的xy 分量按之前提到的公式映射回法线方向,然后乘以 _BumpScale (控制凹凸程度〉来得到tangentNormal 的xy 分量。由于法线都是单位矢量,因此tangentNormal.z 分量可以由tangentNonnal.xy 计算而得。由于我们使用的是切线空间下的法
线纹理, 因此可以保证法线方向的z 分量为正。在Unity 中,为了方便Unity 对法线纹理的存储进行优化,我们通常会把法线纹理的纹理类型标识成Normal map, Unity 会根据平台来选择不同的压缩方法。这时,如果我们再使用上面的方法来计算就会得到错误的结果,因为此时 _BumpMap的rgb 分量并不再是切线空间下法线方向的xyz 值了。在7.2.4 节中,我们会具体解释。在这种情况下,我们可以使用Unity 的内置函数UnpackNormal 来得到正确的法线方向。
(11) 最后,我们为该Unity Shader 设置合适的Fallback:
FallBack "Specular"
保存后返回Unity 中查看。在Nonna!MapTangentSpaceMat 的面版上,我们使用本书资源中的Brick_Diffuse.jpg 和 Brick_Nonnal.jpg 纹理对其赋值。我们可以调整材质面板中的Bump Scale 属性来改变模型的凹凸程度。图7.15 给出了不同的Bump Scale 属性值下得到的结果。
2. 在世界空间下计算
现在,我们来实现第二种方法,即在世界空间下计算光照模型。我们需要在片元着色器中把法线方向从切线空间变换到世界空间下。这种方法的基本思想是:在顶点着色器中计算从切线空间到世界空间的变换矩阵,并把它传递给片元着色器。变换矩阵的计算可以由顶点的切线、副切线和法线在世界空间下的表示来得到。最后,我们只需要在片元着色器中把法线纹理中的法线方向从切线空间变换到世界空间下即可。尽管这种方法需要更多的计算,但在需要使用Cubemap 进行环境映射等情况下,我们就需要使用这种方法。
为此,我们进行如下准备工作。
(1)使用上一节中使用的场景。
(2) 新建一个材质。在本书资源中, 该材质名为Norma!MapWorldSpaceMat 。
(3)新建一个Unity Shader。在本书资源中,该Shader 名为Chapter7-NormalMapWorldSpace.把新的Shader 赋给第2 步中创建的材质。
(4)把第2 步中创建的材质赋给胶囊体。
打开Chapter7-NormalMapWorldSpace,把上一节’中的代码粘贴进去,并进行如下修改:
(1)我们需要修改顶点着色器的输出结构体v2f,使它包含从切线空间到世界空间的变换矩阵:
struct v2f {
float4 pos : SV_POSITION;
float4 uv : TEXCOORD0;
float4 TtoW0 : TEXCOORD1;
float4 TtoW1 : TEXCOORD2;
float4 TtoW2 : TEXCOORD3;
};
我们在3.3.2 节中讲到, 一个插值寄存器最多只能存储 float4 大小的变量,对于矩阵这样的变量,我们可以把它们按行拆成多个变盘再进行存储。上面代码中的TtoW0 、TtoW1 和TtoW2 就依次存储了从切线空间到世界空间的变换矩阵的每一行。实际上,对方向矢量的变换只需要使用3 × 3大小的矩阵,也就是说,每一行只需要使用float3 类型的变量即可。但为了充分利用插值寄存器的存储空间,我们把世界空间下的顶点位置存储在这些变量的w 分量中。
(2)修改顶点着色器,计算从切线空间到世界空间的变换矩阵:
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 = 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;
}
在上面的代码中,我们计算了世界空间下的顶点切线、副切线和法线的矢量表示, 并把它们按列摆放得到从切线空间到世界空间的变换矩阵。我们把该矩阵的每一行分别存储在TtoW0 、TtoWl1和TtoW2 中, 并把世界空间下的顶点位置的xyz 分量分别存储在了这些变量的w 分量中,以便充分利用插值寄存器的存储空间。
(3 )修改片元着色器,在世界空间下进行光照计算:
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 narmal 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);
}
我们首先从TtoW0 、TtoW1和TtoW2 的w 分量中构建世界空间下的坐标。然后,使用内置的UnityWorldSpaceLightDir 和UnityWorldSpaceViewDir 函数得到世界空间下的光照和视角方向。
接着,我们使用内置的UnpackNormal 函数对法线纹理进行采样和解码(需要把法线纹理的格式标识成Normal map ), 并使用_BumpScale 对其进行缩放。最后,我们使用TtoW0 、TtoW1 和TtoW2存储的变换矩阵把法线变换到世界空间下。这是通过使用点乘操作来实现矩阵的每一行和法线相乘来得到的。
从视觉表现上,在切线空间下和在世界空间下计算光照几乎没有任何差别。在Unity 4.x 版本中,在不需要使用Cubemap 进行环境映射的情况下, 内置的Unity Shader 使用的是切线空间来进行法线映射和光照计算。而在Unity 5 .x 中, 所有内置的Unity Shader 都使用了世界空间来进行光照计算。这也是为什么Unity 5.x 中表面着色器更容易报错,因为它们使用了更多的插值寄存器来存储变换矩阵(还有一些额外的插值寄存器是用来辅助计算雾效的, 更多内容可以参见19 .2 节〉。
7.2.4 Unity 中的法线纹理类型
上面我们提到了当把法线纹理的纹理类型标识成Normal map 时,可以使用Unity 的内置函数UnpackNormal 来得到正确的法线方向,如图7.16所示。
简单来说,这么做可以让Unity 根据不同平台对纹理进行压缩(例如使用DXT5nm 格式,具体的压缩细节可以参考:
http://tech-artists.org/wiki/Normal_map_compression ),再通过UnpackNormal 函数来针对不同的压缩格式对法线纹理进行正确的采样。我们可以在UnityCG.cginc 里找到UnpackNormal 函数的内部实现:
inline fixed3 UnpackNormalDXT5nm (fixed4 packednormal)
{
fixed3 normal;
normal.xy = packednormal.wy * 2 - l;
normal.z = sqrt(l - saturate(dot(normal.xy, normal.xy)));
return normal;
}
inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if defined(UNITY_NO_DXT5nm)
return packednormal.xyz * 2 - l;
#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,这样就可以得到类似图7. 17中的结果。然后,我们就可以把它和切线空间下的法线纹理同等对待了。
当勾选了Create from Grayscale 后,还多出了两个选项——Bumpiness 和Filtering。其中Bumpiness 用于控制凹凸程度,而Filtering 决定我们使用哪种方式来计算凹凸程度,它有两种选项:一种是Smooth ,这使得生成后的法线纹理会比较平滑:另一种是Sharp,它会使用Sobel 滤波(一种边缘检测时使用的滤波器〉来生成法线。Sobel 滤波的实现非常简单,我们只需要在一个3x3的滤波器中计算x 和y 方向上的导数,然后从中得到法线即可。具体方法是: 对于高度图中的每个像素,我们考虑它与水平方向和竖直方向上的像素差,把它们的差当成该点对应的法线在x 和y 方向上的位移,然后使用之前提到的映射函数存储成到法线纹理的r 和g 分量即可。
7.3 渐变纹理
尽管在一开始,我们在渲染中使用纹理是为了定义一个物体的颜色,但后来人们发现,纹理其实可以用于存储任何表面属性。一种常见的用法就是使用渐变纹理来控制漫反射光照的结果。
在之前计算漫反射光照时,我们都是使用表面法线和光照方向的点积结果与材质的反射率相乘来得到表面的漫反射光照。但有时,我们需要更加灵活地控制光照结果。这种技术在游戏《军团要塞2》(英文名:《Team Fortress 2》〉中流行起来,它也是由Valve 公司(提出半兰伯特光照技术的公司〉提出来的,他们使用这种技术来渲染游戏中具有插画风格的角色。Valve 发表了一篇著名的论文来专门讲述在制作《军团要塞2》时使用的技术。这种技术最初由Gooch 等人在1998 年他们发表的一篇著名的论文
《A Non-Photorealistic Lighting Model For Automatic Technical Illustration ))中被提出, 在这篇论文中,作者提出了一种基于冷到暖色调(cool-to-warm tones ) 的着色技术,用来得到一种插画风格的渲染效果。使用这种技术,可以保证物体的轮廓线相比于之前使用的传统漫反射光照更加明显,而且能够提供多种色调变化。而现在,很多卡通风格的渲染中都使用了这种技术。我们14.1 节中会专门学习如何编写一个卡通风格的Unity Shader。
在本节中,我们将学习如何使用一张渐变纹理来控制漫反射光照。在学习完本节后,我们可以得到类似图7.18 中的效果。
可以看出,使用这种方式可以自由地控制物体的漫反射光照。不同的渐变纹理有不同的特性。例如, 在左边的图中,我们使用一张从紫色调到浅黄色调的渐变纹理: 而中间的图使用的渐变纹理则和《军团要塞2》中渲染人物使用的渐变纹理是类似的, 它们都是从黑色逐渐向浅灰色靠拢,而且中间的分界线部分微微发红, 这是因为画家在插画中往往会在阴影处使用这样的色调: 右侧的渐变纹理则通常被用于卡迪风格的渲染,这种渐变纹理中的色调通常是突变的, 即没有平滑过渡,以此来模拟卡通中的阴影色块。
为了实现上述效果, 我们需要进行如下准备工作。
( 1 )在Unity 中新建一个场景。在本书资源中,该场最名为Scene_7_3 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光, 并且使用了内置的天空盒子。在Window ->Lighting -> Skybox 中去掉场景中的天空盒子。
( 2 )新建一个材质。在本书资源中, 该材质名为RampTextureMat .
( 3 )新建一个Unity Shader。在本书资源中,该Unity Shader 名为Chapter7-RampTexture 。把新的Unity Shader 赋给第2 步中创建的材质。
( 4 )向场景中拖曳一个Suzanne 模型,并把第2 步中的材质赋给该模型。
( 5 )保存场景。
打开新建的Chapter7-RampTexture,删除所有已有代码,并进行如下修改。
(8 )定义顶点着色器:
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 = TRANSFORM_TEX(v.texcoord, _RampTex);
return o;
}
我们使用了内置的TRANSFORM_TEX 宏来计算经过平铺和偏移后的纹理坐标。
( 9 )接下来是关键的片元着色器:
fixed4 frag(v2f i) : SV_Target {
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// Use the texture to sample the diffuse color
fixed halfLambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
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);
}
在上面的代码中,我们使用6.4.3 节中提到的半兰伯特模型,通过对法线方向和光照方向的点积做一次0.5 倍的缩放以及一个0. 5 大小的偏移来计算半兰伯特部分haltLambert . 这样,我们得到的haltLambert 的范围被映射到了[0 , 1]之间。之后,我们使用haltLambert 来构建一个纹理坐标,并用这个纹理坐标对渐变纹理 _RampTex 进行来样。由于 _RampTex 实际就是一个一维纹理(它在纵轴方向上颜色不变〉,因此纹理坐标的u
和v 方向我们都使用了halfLambert。然后,把从渐变纹理采样得到的颜色和材质颜色 _Color 相乘,得到最终的提反射颜色。剩下的代码就是计算高光反射和环境光,并把它们的结果进行相加。相信读者已经对这些步骤非常熟悉了。
( 10 )最后,我们为该Unity Shader 设置合适的Fallback :
FallBack "Specular"
保再后返回场景。我们在本书资源中提供了多种渐变纹理,如Ramp_Texture0.psd 和 Ramp_Texture1.psd 等。读者可以尝试把不同的渐变纹理拖曳到材质面板查看效果。
需要注意的是,我们需要把渐变纹理的Wrap Mode 设为Clamp 模式,以防止对纹理进行采样时由于浮点数精度而造成的问题。图7.19给出了Wrap Mode 分别为Repeat 和Clamp 模式的效果对比。
![](https://i-blog.csdnimg.cn/blog_migrate/391edb28bc75300851143b8a7141302b.png)
fixed2(haItLambert, haltLambert)对渐变纹理进行采样时,虽然理论上halfLambert 的值在[0, 1 ]之间,但可能会奋1.000 01 这样的值出现。如果我们使用的是Repeat 模式,此时就会舍弃整数部分, 只保留小数部分,得到的值就是0.000 01 ,对应了渐变图中最左边的值,即黑色。因此,就会出现图中这样在高光区域反而有黑点的情况。我们只需要把渐变纹理的Wrap Mode 设为Clamp 模式就可以解决这种问题。
7.4 遮罩纹理
遮罩纹理( mask texture ) 是本章要介绍的最后一种纹理,它非常有用, 在很多商业游戏中都可以见到它的身影。那么什么是遮罩呢?简单来讲,遮罩允许我们可以保护某些区域,使它们免于某些修改。例如,在之前的实现中,我们都是把高光反射应用到模型表面的所有地方,即所有的像素都使用同样大小的高光强度和高光指数。但有时,我们希望模型表面某些区域的反光强烈一些,而某些区域弱一些。为了得到更加细腻的效果, 我们就可以使用一张遮罩纹理来控制光照。另一种常见的应用是在制作地形材质时需要混合多张图片,例如表现草地的纹理、表现石子的纹理、表现裸露土地的纹理等,使用遮罩纹理可以控制如何混合这些纹理。
使用遮罩纹理的流程一般是: 通过采样得到遮罩纹理的纹素值, 然后使用其中某个(或某几个)通道的值(例如 texel.r)来与某种表面属性进行相乘,这样,当该通道的值为0 时,可以保护表面不受该属性的影响。总而育之, 使用遮罩纹理可以让美术人员更加精准(像素级别)地控制模型表面的各种性质。
7.4.1 实践
在本节中, 我们将学习如何使用一张高光遮罩纹理, 逐像素地控制模型表面的高光反射强度。图17.20 显示了只包含漫反射、未使用遮罩的高光反射和使用遮罩的高光反射的对比效果。我们使用的遮罩纹理如图7.21 所示。可以看出, 遮罩纹理可以让我们更加精细地控制光照细节,得到更细腻的效果。
![](https://i-blog.csdnimg.cn/blog_migrate/be9bc2f12f45917b4ed387607aad6e23.png)
(1 )在Unity 中新建一个场景。在本书资源中, 该场景名为Scene_7_4 。在Unity 5.2 中, 默认情况下场景将包含一个摄像机和一个平行光, 并且使用了内置的天空盒子。在Window -> Lighting -> Skybox 中去掉场景中的天空盒子。
( 2 )新建一个材质。在本书资源中, 该材质名为MaskTextureMat。
(3 )新建一个Unity Shader。在本书资源中,该UnityShader 名为Chapter7-MaskTexture . 把新的Unity Shader赋给第2 步中创建的材质。
( 4 )在场景中创建一个胶囊体, 并把第2 步中的材质赋给该胶囊体。
( 5 )保存场景。
打开新建的Chapter7-MaskTexture, 删除所有已有代码,并进行如下修改:
( 1 )首先, 我们需要为这个Shader 起一个名字:
Shader "Unity Shaders Book/Chapter 7/Mask Texture" {
( 2 ) 我们需要在Properties 语义块中声明更多的变量来控制高光反射:
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
}
上面属性中的 _SpecularMask 即是我们需要使用的高光反射遮罩纹理,_SpecularScale 则是用于控制遮罩影响度的系数。
( 6 )随后,我们需要定义和Properties 中各个属性类型相匹配的变量:
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float _BumpScale;
sampler2D _SpecularMask;
float _SpecularScale;
fixed4 _Specular;
float _Gloss;
我们为主纹理 _MainTex 、法线纹理 _BumpMap 和遮罩绞理 _SpecularMask 定义了它们共同使用的纹理属性变量 _MainTex_ST。这意味着,在材质面板中修改主纹理的平铺系数和偏移系数会同时影响3 个纹理的采样。使用这种方式可以让我们节省需要存储的纹理坐标数目,如果我们为每一个纹理都使用一个单独的属性变量 TextureName_ST,那么随着使用的纹理数目的增加,我们会迅速占满顶点着色器中可以使用的插值寄存器。而很多时候,我们不需要对纹理进行平铺和位移操作,或者很多纹理可以使用同一种平铺和位移操作,此时我们就可以对这些纹理使用同一个变换后的纹理坐标进行采样。
(7 )定义顶点着色器的输入和输出结构体:
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;
};
( 8 )在顶点着色器中,我们对光照方向和视角方向进行了坐标空间的变换,把它们从模型空间变换到了切线空间中,以便在片元着色器中和法线进行光照运算:
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;
}
(9 )使用遮罩纹理的地方是片元着色器。我们使用它来控制模型表面的高光反射强度:
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);
// Get the mask value
fixed specularMask = tex2D(_SpecularMask, i.uv).r * _SpecularScale;
// Compute specular term with the specular mask
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss) * specularMask;
return fixed4(ambient + diffuse + specular, 1.0);
}
环境光照和漫反射光照和之前使用过的代码完全一样。在计算高光反射时,我们首先对遮罩纹理 _SpecularMask 进行采样。由于本书使用的遮罩纹理中每个纹素的 rgb 分量其实都是一样的,表明了该点对应的高光反射强度, 在这里我们选择使用 r 分量来计算掩码值。然后,我们用得到的掩码值和 _SpecularScale 相乘, 一起来控制高光反射的强度。
需要说明的是,我们使用的这张遮罩纹理其实有很多空间被浪费了一一它的 rgb 分量存储的都是同一个值。在实际的游戏制作中,我们往往会充分利用遮罩纹理中的每一个颜色通道来存储不同的表面属性,我们会在7.4.2 节中介绍这样一个例子。
( 10)最后,我们为该Unity Shader 设置了合适的Fallback:
FallBack "Specular"
7.4.2 其他遮罩纹理
在真实的游戏制作过程中,遮罩纹理已经不止限于保护某些区域使它们免于某些修改,而是可以存储任何我们希望逐像素控制的表面属性。通常,我们会充分利用一张纹理的RGBA 四个通道,用于存储不同的属性。例如,我们可以把高光反射的强度存储在R 通道,把边缘光照的强度存储在G 通道,把高光反射的指数部分存储在B 通道,最后把自发光强度存储在A 通道。
在游戏《DOTA 2》的开发中,开发人员为每个模型使用了4 张纹理:一张用于定义模型颜色,一张用于定义表面法线,另外两张则都是遮罩纹理。这样,两张遮罩纹理提供了共8 种额外的表面属性,这使得游戏中的人物材质自由度很强,可以支持很多高级的模型属性。读者可以在他们的官网上找到关于《DOTA 2》的更加详细的制作资料,包括游戏中的人物模型、纹理以及制作手册等。这是非常好的学习资料。