第7章 基础纹理
纹理最初的目的就是使用一张图片来控制模型的外观。使用纹理映射技术(texture mapping)
,我们可以把一张图黏在模型表面,逐纹素(texel)(纹素的名字是为了和像素进行区分)
地控制模型的颜色。
在美术人员建模时,通常会在建模软件中利用纹理展开技术把纹理映射坐标存储在每个顶点上。纹理映射坐标定义了该顶点在纹理中对应的 2D 坐标,这些坐标使用一个二维变量(u, v)来表示,因此纹理映射坐标也被称为UV坐标
。
尽管纹理的大小是多种多样的,但是顶点UV坐标的范围通常会被归一化到[0,1]范围内。
需要注意的是,纹理采样时使用的纹理坐标不一定是在[0, 1]范围内。实际上,这种不在[0, 1]范围内的纹理坐标有时会非常有用。与之关系紧密的是纹理的平铺模式
,它将决定渲染引擎在遇到不在[0, 1]范围内的纹理坐标时如何进行纹理采样。
7.1 单张纹理
实践
实现的使用单张纹理的 Shader 如下:
Shader "Chaptor 7/SHA_SingleTexture"
{
Properties
{
_Color ("Color Tint", Color) = (1, 1, 1, 1)
// Properties 添加纹理以便在编辑器中设置,声明关键字为 2D
_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 "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Color;
sampler2D _MainTex;
// 为纹理 _MainTex 的属性信息,ST 是缩放(scale)和平移(translation)的缩写,这些值可以在材质面板的纹理属性中调节
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 i)
{
v2f o;
o.pos = UnityObjectToClipPos(i.vertex);
o.worldNormal = UnityObjectToWorldNormal(i.normal);
o.worldPos = UnityObjectToWorldDir(i.vertex);
// 使用纹理的属性值对顶点纹理的坐标进行变换,得到最终的纹理坐标
// #define TRANSFROM_TEX(tex, name) (tex.xy * name##_ST.xy + name##_ST.zw)
// 下面的代码等价于 o.uv = _MainTex.xy * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv = TRANSFORM_TEX(i.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(UnityObjectToViewPos(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中导入一张纹理资源后,可以在它的材质面板上调整其属性。此处只简略说明一下它的一些重要属性。
Wrap Mode
Wrap Mode 决定了当纹理坐标超过[0, 1]范围后将会如何被平铺。下面是 Wrap Mode 的两种常见模式:
- Repeat:在这种模式下,如果纹理坐标超过了1,那么它的整数部分将会被舍弃,而直接使用小数部分进行采样,这样的结果是纹理将会不断重复。
- Clamp: 在这种模式下,如果纹理坐标大于1,那么将会截取到1,如果小于0,那么将会截取到0。
下图展示了在纹理的平铺(Tiling)属性为(3, 3)时分别使用两种Wrap Mode的结果
需要注意的是,想要让纹理得到这样的效果,我们必须使用纹理的属性在Unity Shader中对顶点纹理坐标进行相应的变换:
o.uv = TRANSFORM_TEX(i.texcoord, _MainTex);
Filter Mode
Filter Mode 决定了当纹理由于变换而产生拉伸时将会采用哪种滤波模式,纹理滤波会影响放大或缩小纹理时得到的图片质量。
纹理缩小的过程比放大更加复杂一些,此时原纹理中的多个像素将会对应一个目标像素。纹理缩小更加复杂的原因在于我们往往需要处理抗锯齿问题
,一个最常使用的方法就是使用多级渐远纹理(mipmapping)
技术。多级渐远纹理技术将原纹理提前用滤波处理来得到很多更小的图像,形成了一个图像金字塔,每一层都是对上一层图像降采样的结果。这样在实时运行时,就可以快速得到结果像素,例如当物体远离摄像机时,可以直接使用较小的纹理。但缺点是需要使用一定的空间用于存储这些多级渐远纹理,通常会多占用33%的内存空间。这是一种典型的用空间换取时间的方法。在Unity中,我们可以在纹理导入面板中,首先将纹理类型选择成 Advanced,再勾选 Generate Mip Maps 即可开启多级渐远纹理技术。
Filter Mode支持3种模式:
- Point:Point 模式使用了
最近邻(nearest neighbor)滤波
,在放大或缩小时,它的采样像素数目通常只有一个,因此图像会看起来有种像素风格的效果。 - Bilinear:Bilinear 滤波则使用了线性滤波,对于每个目标像素,它会找到4个邻近像素,然后对它们进行线性插值混合后得到最终像素,因此图像看起来像被模糊了。
- Trilinear:Trilinear 滤波几乎是和 Bilinear 一样的,只是 Trilinea r还会在多级渐远纹理之间进行混合。如果一张纹理没有使用多级渐远纹理技术,那么 Trilinear 得到的结果是和Bilinear就一样的。
- Point、Bilinear、Trilinear三种得到的图片滤波效果依次提升,但需要耗费的性能也依次增大。通常,我们会选择Bilinear滤波模式。
纹理的最大尺寸和纹理模式
Unity 允许我们为不同目标平台选择不同的分辨率,且可以设置纹理的最大尺寸:
如果导入的纹理大小超过了Max Size
中的设置值,那么 Unity 将会把该纹理缩放为这个最大分辨率。理想情况下,导入的纹理可以是非正方形的,但长宽的大小应该是2的幂,如果使用了非2的幂大小(Non Power of Two,NPOT)
的纹理,那么这些纹理往往会占用更多的内存空间,而且GPU读取该纹理的速度也会有所下降。有一些平台甚至不支持这种NPOT纹理,这时Unity在内部会把它缩放成最近的2的幂大小。出于性能和空间的考虑,我们应该尽量使用2的幂大小的纹理。
而 Format 决定了 Unity 内部使用哪种格式来存储该纹理。需要知道的是,使用的纹理格式精度越高,占用的内存空间越大,但得到的效果也越好。我们可以从纹理导入面板的最下方看到存储该纹理需要占用的内存空间(如果开启了多级渐远纹理技术,也会增加纹理的内存占用):
7.2 凹凸映射
纹理的另一种常见的应用就是凹凸映射(bump mapping)
。凹凸映射的目的就是使用一张纹理来修改模型的法线,以便为模型提供更多的细节,这种方法不会真的修改模型的顶点位置,只是让模型看起来好像是凹凸不平的。
凹凸映射的主要方法有两种:
- 高度映射(height mapping): 使用一张
高度纹理(height map)
来模拟表面位移,然后得到一个修改后的法线值。 - 法线映射(normal mapping): 直接使用一张
法线纹理(normal map)
来存储表面法线。
高度纹理
一张高度纹理类似下图:
高度图中存储的是强度值(intensity)
,它用于表示模型表面局部的海拔高度。因此,颜色越浅表明该位置的表面越向外凸起,而颜色越深表明该位置越向里凹。这种方法的好处是非常直观,我们可以从高度图中明确地知道一个模型表面的凹凸情况,但缺点是计算更加复杂,在实时计算时不能直接得到表面法线,而是需要由像素的灰度值计算而得,因此需要消耗更多的性能。
法线纹理
法线纹理中存储的就是表面的法线方向。由于法线方向的分量范围在[−1, 1],而像素的分量范围为[0, 1],因此我们需要做一个映射:
pixel = (normal + 1) / 2
而在 shader 中还需要进行一次反映射才能得到法线方向:
normal = pixel*2 - 1
法线纹理基于其中法线方向的坐标空间又分为模型空间的法线纹理(object-space normal map)
和切线空间的法线纹理(tangent-space normal map)
。
对于模型顶点自带的法线,它们是定义在模型空间中的,因此一种直接的想法就是将修改后的模型空间中的表面法线存储在一张纹理中,这种纹理被称为是模型空间的法线纹理。模型空间下的法线纹理看起来是“五颜六色”的。这是因为所有法线所在的坐标空间是同一个坐标空间,即模型空间,而每个点存储的法线方向是各异的:
在实际制作中,我们往往会采用另一种坐标空间,即模型顶点的切线空间来存储法线。对于模型的每个顶点,它都有一个属于自己的切线空间,这个切线空间的原点就是该顶点本身,而 z 轴是顶点的法线方向(n),x轴是顶点的切线方向(t),而y轴可由法线和切线叉积而得,也被称为是副切线(bitangent,b)或副法线:
切线空间下的法线纹理看起来几乎全部是浅蓝色的。这是因为,每个法线方向所在的坐标空间是不一样的,即是表面每点各自的切线空间。这种法线纹理其实就是存储了每个点在各自的切线空间中的法线扰动方向。也就是说,如果一个点的法线方向不变,那么在它的切线空间中,新的法线方向就是 z 轴方向,即值为(0, 0, 1),经过映射后存储在纹理中就对应了 RGB(0.5, 0.5, 1) 浅蓝色。这些蓝色实际上说明顶点的大部分法线是和模型本身法线一样的,不需要改变:
使用模型空间的法线纹理有如下优点:
- 实现简单,更加直观。我们使用时不需要原始模型的法线和切线信息,计算量更少
- 在纹理坐标的缝合处和尖锐的边角部分,可以提供更平滑的边界,因为模型空间下的法线纹理存储在同一坐标系下,因此在边界处通过差值得到的法线可以平滑变换。
但使用切线空间有更多优点:
- 自由度更高。模型空间下的法线纹理记录的是绝对法线信息,仅可用于生成它时的模型,无法应用到其他模型上。而切线空间下记录的是相对法线信息,所以可以应用到一个不同的网格上。
- 可进行 UV 动画。我们可以通过移动一个纹理的 UV 坐标哎实现一个凹凸移动的效果,这种动画在水或者熔岩这类物体中经常用到。
- 可以重用法线纹理
- 便于压缩。由于切线空间下的法线纹理中法线的Z方向总是正方向,因此我们可以仅存储 XY 方向,而推导得到 Z 方向。而模型空间下的法线纹理由于每个方向都是可能的,因此必须存储3个方向的值,不可压缩。
实际项目中大家往往更加喜欢使用切线空间的法线纹理。
实践
我们需要在计算光照模型中统一各个方向矢量所在的坐标空间。由于法线纹理中存储的法线通常是切线空间下的方向,因此我们通常有两种选择:**一种选择是在切线空间下进行光照计算,此时我们需要把光照方向、视角方向变换到切线空间下;另一种选择是在世界空间下进行光照计算,此时我们需要把采样得到的法线方向变换到世界空间下,再和世界空间下的光照方向和视角方向进行计算。**从效率上来说,第一种方法往往要优于第二种方法,因为我们可以在顶点着色器中就完成对光照方向和视角方向的变换,而第二种方法由于要先对法线纹理进行采样,所以变换过程必须在片元着色器中实现,这意味着我们需要在片元着色器中进行一次矩阵操作。但从通用性角度来说,第二种方法要优于第一种方法,因为通常我们需要在世界空间下进行一些额外计算。
在切线空间下计算
Shader "Chaptor 7/SHA_NormalMapTangentSpace"
{
Properties
{
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
// 声明法线纹理,使用 bump 作为默认值,bump 是 Unity 内置的法线纹理
_BumpMap ("Bump Map", 2D) = "bump" {}
// Bump Scale 用于控制凹凸程度,当它为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 "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Specular;
float _Gloss;
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
// 使用 TANGENT 语义来描述 float4 的变量,以告诉 Unity 将顶点的切线方向填充至 tangent 中
float4 tangent : TANGENT;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float4 uv : TEXCOORD0;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
// o.uv 设置为 float4 类型,xy存储 _MainTex 的纹理坐标,zw 存储 _BumpMap 的纹理坐标
// 实际使用中不必如此计算,因为 _MainTex 和 _BumpMap 通常会使用同一组纹理坐标,出于减少插值寄存器的使用数目的目的,我们往往只计算和存储一个纹理坐标即可
o.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
o.uv.zw = TRANSFORM_TEX(v.uv, _BumpMap);
// 内置宏,计算切线空间的变换矩阵
// 算法是将模型空间下的切线方向、副切线方向、法线法线按行排列即得
// 等价于如下代码:
// float3 binormal = cross( normalize(v.normal), normalize(v.tangent.xyz) ) * v.tangent.w;
// float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
TANGENT_SPACE_ROTATION;
// 计算切线空间下的光照方向和视角方向, ObjSpaceLightDir 和 ObjSpaceViewDir 为内置函数,用于得到模型空间下的光照和视角方向
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);
// 对纹理法线值进行反映射,得到真正的法线向量
// UnpackNormal 为内置函数,等价于如下代码:
// fixed3 tangentNormal;
// tangentNormal.xy = packedNormal.xyz * 2 -1;
fixed3 tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
// tangentNormal 的 z 分量可以通过 xy 计算而得
// 由于使用的是切线空间下的法线纹理,因此可以保证法线方向的 z 分量为正
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 的值来改变模型的凹凸程度。
在世界坐标下计算
Shader "Chaptor 7/SHA_NormalMapWorldSpace"
{
Properties
{
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
// 声明法线纹理,使用 bump 作为默认值,bump 是 Unity 内置的法线纹理
_BumpMap ("Bump Map", 2D) = "bump" {}
// Bump Scale 用于控制凹凸程度,当它为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 "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Specular;
float _Gloss;
struct appdata
{
float4 vertex : POSITION;
fixed3 normal : NORMAL;
// 使用 TANGENT 语义来描述 float4 的变量,以告诉 Unity 将顶点的切线方向填充至 tangent 中
float4 tangent : TANGENT;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float4 uv : TEXCOORD0;
// 一个插值寄存器最多只能存储 float4 大小的变量,对于矩阵这样的变量,我们可以把它拆成多个变量进行存储
// 实际上,对方向向量的变换只需要使用 3x3 的矩阵,也就是说,下面的变量声明为 float3 即可
// 但为了充分利用插值寄存器的存储空间,我们可以用下面变量的 w 分量来存储世界坐标下的顶点位置
float4 TtoW0 : TEXCOORD1;
float4 TtoW1 : TEXCOORD2;
float4 TtoW2 : TEXCOORD3;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
// o.uv 设置为 float4 类型,xy存储 _MainTex 的纹理坐标,zw 存储 _BumpMap 的纹理坐标
// 实际使用中不必如此计算,因为 _MainTex 和 _BumpMap 通常会使用同一组纹理坐标,出于减少插值寄存器的使用数目的目的,我们往往只计算和存储一个纹理坐标即可
o.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
o.uv.zw = TRANSFORM_TEX(v.uv, _BumpMap);
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;
// 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
{
float 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 bumpNormal = UnpackNormal(packedNormal);
bumpNormal.xy *= _BumpScale;
bumpNormal.z = sqrt(1.0 - saturate(dot(bumpNormal.xy, bumpNormal.xy)));
// 将 bumpNormal 从切线空间转换至世界空间
bumpNormal = normalize(half3(dot(i.TtoW0.xyz, bumpNormal), dot(i.TtoW1.xyz, bumpNormal), dot(i.TtoW2.xyz, bumpNormal)));
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.rgb * albedo * max(0, dot(bumpNormal, lightDir));
fixed3 halfDir = normalize(lightDir + viewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(bumpNormal, halfDir)), _Gloss);
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
Fallback "Specular"
}
Unity 中的法线纹理类型
当我们需要使用那些包含了法线映射的 Unity 内置 Shader 时,必须把使用的法线纹理设置为 Normal Map
,因为这些 Shader 都使用了内置的 UnpackNormal
函数来采样法线纹理,而此内置函数值对 Normal Map 类型的纹理才生效:
将我们把法线纹理设置为 Normal Map 类型时,Unity 便可以根据平台对纹理进行压缩,例如使用 DXT5nm
格式。在DXT5nm格式的法线纹理中,纹素的 a 通道对应了法线的 x 分量,g 通道对应了法线的 y 分量,而纹理的 r 和 b 通道则会被舍弃,法线的 z 分量可以由 xy 分量推导而得。
当我们把纹理类型设置成 Normal map 后,还有一个复选框是 Create from Grayscale
,这个复选框用于从高度图中生成切线空间的法线纹理。
7.3 渐变纹理
尽管在一开始,我们在渲染中使用纹理是为了定义一个物体的颜色,但后来人们发现,纹理其实可以用于存储任何表面属性。一种常见的用法就是使用渐变纹理来控制漫反射光照的结果。
这种技术最初由 Gooch 等人在1998年他们发表的一篇著名的论文《A Non-Photorealistic Lighting Model For Automatic Technical Illustration》
中被提出,在这篇论文中,作者提出了一种基于冷到暖色调(cool-to-warm tones)
的着色技术,用来得到一种插画风格的渲染效果。使用这种技术,可以保证物体的轮廓线相比于之前使用的传统漫反射光照更加明显,而且能够提供多种色调变化。而现在,很多卡通风格的渲染中都使用了这种技术。
下面是一个 Shader 实例:
// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
Shader "Chaptor 7/SHA_RampTexture"
{
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 "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Color;
sampler2D _RampTex;
float4 _RampTex_ST;
fixed4 _Specular;
float _Gloss;
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
float2 uv : TEXCOORD2;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _RampTex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed3 worldNormal = normalize(i.worldNormal);
fixed3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
fixed3 worldViewDir = UnityWorldSpaceViewDir(i.worldPos);
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 使用半兰伯特光照模型
fixed halfLambert = 0.5 * dot(worldNormal, worldLightDir) + 0.5;
// 漫反射颜色去渐变纹理中取样
fixed3 diffuseColor = tex2D(_RampTex, fixed2(halfLambert, halfLambert)).rgb * _Color;
fixed3 diffuse = _LightColor0.rgb * diffuseColor;
fixed3 halfDir = normalize(worldLightDir + worldViewDir);
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(worldNormal, halfDir)), _Gloss);
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
}
下面是三种渐变纹理对应的效果图:
需要注意的是,我们需要把渐变纹理的Wrap Mode设为Clamp模式,以防止对纹理进行采样时由于浮点数精度而造成的问题。当我们使用 fixed2(halfLambert, halfLambert) 对渐变纹理进行采样时,虽然理论上halfLambert 的值在[0, 1]之间,但可能会有1.00001这样的值出现。如果我们使用的是 Repeat 模式,此时就会舍弃整数部分,只保留小数部分,得到的值就是0.00001,对应了渐变图中最左边的值,即黑色。因此,就会出现下图中这样在高光区域反而有黑点的情况:
7.4 遮罩纹理
遮罩纹理(mask texture)是非常有用的一种纹理,它允许我们可以保护某些区域,使它们免于某些修改。使用遮罩纹理的流程一般是:通过采样得到遮罩纹理的纹素值,然后使用其中某个(或某几个)通道的值来与某种表面属性进行相乘,这样,当该通道的值为0时,可以保护表面不受该属性的影响。总而言之,使用遮罩纹理可以让美术人员更加精准(像素级别)地控制模型表面的各种性质。
实践
Shader "Chaptor 7/SHA_MaskTexture"
{
Properties
{
_Color ("Color Tint", Color) = (1, 1, 1, 1)
_MainTex ("Main Tex", 2D) = "white" {}
_BumpMap ("Bump Map", 2D) = "bump" {}
_BumpScale ("Bump Scale", Float) = 1.0
// 遮罩纹理
_SpecularMask ("Specular Tex", 2D) = "white" {}
// 用于控制遮罩影响度
_SpecularMaskScale ("Specular Mask 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 "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Color;
sampler2D _MainTex;
// 此处纹理属性为三张纹理共用,这意味着,在材质面板中修改主纹理的平铺系数和偏移系数会同时影响3个纹理的采样。
// 使用这种方式可以让我们节省需要存储的纹理坐标数目。
// 如果我们为每一个纹理都使用一个单独的属性变量,那么随着使用的纹理数目的增加,我们会迅速占满顶点着色器中可以使用的插值寄存器。
// 很多时候,我们不需要对纹理进行平铺和位移操作,或者很多纹理可以使用同一种平铺和位移操作
float4 _MainTex_ST;
sampler2D _BumpMap;
float _BumpScale;
sampler2D _SpecularMask;
float _SpecularMaskScale;
fixed4 _Specular;
float _Gloss;
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float3 lightDir : TEXCOORD1;
float3 viewDir : TEXCOORD2;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
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);
// 对遮罩纹理进行采样,并使用它的 R 通道值配合影响度参数来控制高光强度
fixed specularMask = tex2D(_SpecularMask, i.uv).r * _SpecularMaskScale;
fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(max(0, dot(tangentNormal, halfDir)), _Gloss) * specularMask;
return fixed4(ambient + diffuse + specular, 1.0);
}
ENDCG
}
}
Fallback "Specular"
}
其他遮罩纹理
在真实的游戏制作过程中,遮罩纹理已经不止限于保护某些区域使它们免于某些修改,而是可以存储任何我们希望逐像素控制的表面属性。通常,我们会充分利用一张纹理的 RGBA 四个通道,用于存储不同的属性。例如,我们可以把高光反射的强度存储在 R 通道,把边缘光照的强度存储在 G 通道,把高光反射的指数部分存储在 B通道,最后把自发光强度存储在 A 通道。