纹理最初的目的就是使用一张图片控制模型的外观。使用纹理映射技术,我们可以将一张图片黏在模型表面,逐纹素地控制模型的颜色。
在美术人员建模时,通常会在建模软件中利用纹理展开技术把纹理映射坐标存储在每个顶点上。纹理映射坐标定义了该顶点在纹理中对应的2D坐标。通常,这些坐标使用一个二维向量(u,v)表示,其中u是横向坐标,v是纵向坐标。因此纹理映射坐标也被称为uv坐标。
一、单张纹理
我们还是在之前的Shader基础上进行修改。首先复制一份之前的逐像素高光反射Shader
Shader "Unlit/TextureSampling"
{
Properties
{
_Diffuse("Diffuse",Color) = (1,1,1,1)
_Specular("Specular",Color) = (1,1,1,1)
_Gloss("Gloss",Range(1,256)) = 20
}
SubShader
{
Tags {
"LightMode"="ForwardBase" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
fixed4 _Diffuse;
fixed4 _Specular;
float _Gloss;
struct v2f
{
float4 vertex: SV_POSITION;
fixed3 worldNormal: TEXCOORD0;
fixed3 worldVert: TEXCOORD1;
};
v2f vert(appdata_base v)
{
v2f o;
// 将顶点坐标从物体空间转换到裁剪空间
o.vertex = UnityObjectToClipPos(v.vertex);
// 将法线从物体空间转换到世界空间
const fixed3 worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldNormal = worldNormal;
// 缓存顶点的世界坐标
o.worldVert = mul(unity_ObjectToWorld, v.vertex);
return o;
}
fixed4 frag(v2f i):SV_Target
{
// 获取归一化的光源方向
const fixed3 worldLight = normalize(_WorldSpaceLightPos0);
// 环境光
const fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
// 计算漫反射
const fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * saturate(dot(i.worldNormal,worldLight));
// 反射光向量
const fixed3 reflectDir = normalize(reflect(-worldLight, i.worldNormal));
// 视角方向向量 摄像机位置-顶点位置
const fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldVert);
// 计算高光反射
const fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(reflectDir, viewDir)), _Gloss);
fixed3 color = ambient + diffuse+specular;
return fixed4(color,1);
}
ENDCG
}
}
}
在属性中定义一个纹理属性。其中“white”指的是内置纹理的名字,即一个全白纹理
_MainTex("MainTex",2D) = "white" {
}
然后在CG代码中声明对应的变量。这里要注意,除了需要声明_MainTex
变量外,还需要声明一个_MainTex_ST
变量用来得到缩放和平移值,也就是面板中的「Tiling」和「Offset」。这个名字并不是随便取的,而是Unity规定的使用纹理名_ST
的方式声明某个纹理的属性。
sampler2D _MainTex;
float4 _MainTex_ST;
接下来我们需要在输出结构体中声明一个新的变量uv用来存储纹理坐标
float2 uv:TEXCOORD2;
然后在顶点着色器中计算纹理坐标。在appdata_base中就可以直接获取到,但为了让我们定义的属性可以对其进行控制,我们需要乘上缩放并加上偏移
// 计算uv坐标
o.uv = v.texcoord.xy*_MainTex_ST.xy+_MainTex_ST.zw;
最后在片元着色器中对其进行纹理采样,可以直接使用tex2D
函数,它的第一个参数是需要被采样的纹理,第二个参数是一个float类型的纹理坐标。它将返回计算得到的纹素值。我们直接将返回值乘到漫反射计算公式中
const fixed3 albedo = tex2D(_MainTex,i.uv).rgb;
const fixed3 diffuse = _LightColor0.rgb * albedo * _Diffuse.rgb * saturate(dot(i.worldNormal, worldLight));
最后在面板中选择一张图片,效果如下
二、凹凸映射
所谓的凹凸映射就是使用一张纹理来修改模型表面的法线(法线贴图),来让模型看起来像是凹凸不平的效果。在之前计算漫反射时,光的入射方向是不变的,唯一能确定计算结果的是法线方向。对于一个凹凸不平的物体表面,其法线方向也是不同的。那么要想让一个光滑的物体表面看起来凹凸不平,也只需要改变其法线方向即可
实现凹凸映射主要有两种方式,一种是使用高度纹理来模拟表面位移,然后得到一个修改后的法线值,这种方法也叫高度映射。另一种方法是使用一张法线纹理来直接存储表面法线,这种方法也叫法线映射。
2.1 高度纹理
高度图中存储的是强度值,用来表示模型表面局部的海拔高度,图中颜色越浅表明该位置的表面越凸出,反之越内凹。我们可以通过PS中的「滤镜->3D->生成凹凸图」来生成一张图片的高度图。
高度图的优点是非常直观,我们可以很明确的知道一个模型表面的凹凸情况。但缺点是在实时计算时不能直接得到表面法线,而是需要根据像素的灰度值计算而来,因此更耗费性能。
2.2 法线纹理
法线纹理中存储的是表面的法线方向。由于法线方向的各个分量范围在 [ − 1 , 1 ] [-1,1] [−1,1]之间,而像素的分量范围在 [ 0 , 1 ] [0,1] [0,1]之间,所以需要进行映射
p i x e l = n o r m a l ∗ 0.5 + 0.5 pixel = normal*0.5 + 0.5 pixel=normal∗0.5+0.5
也就是说,我们在Shader中对法线纹理进行纹理采样后,还要对结果进行一次逆映射,即
n o r m a l = p i x e l ∗ 2 − 1 normal = pixel*2 - 1 normal=pixel∗2−1
根据法线纹理中存储的法线所在坐标空间的不同,法线纹理还分为模型空间法线纹理和切线空间法线纹理。下图中左侧的是模型空间法线纹理,右侧的是切线空间法线纹理。可以发现,前者是五颜六色的,而后者确是比较单一的蓝紫色。这是因为前者所有法线所在的坐标空间是同一个空间,即模型空间。而每个点存储的法线方向是各异的,比如法线(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)浅蓝色。显示为紫色则代表这个顶点的法线与模型的法线偏移较大。
同样的,我们也可以直接使用PS获取到图片的法线纹理,操作是「滤镜->3D->生成法线图」
使用模型空间存储法线的优点如下:
- 实现简单,看起来直观。
- 纹理坐标缝合处和尖锐的边角部分,可见的突变较少。
使用切线空间存储法线的有点更多: - 自由度高,可以应用到不同的模型。
- 可以实现UV动画。
- 可以重用法线纹理。
- 可压缩。
因而在很多情况下,人们都选择使用切线空间存储法线。
2.3 在Unity中实现凹凸映射
这里我们采用切线空法线纹理进行计算。有如下两种方案:
- 在顶点着色器中,将视角、光照方向等变换到切线空间,与法线进行计算,然后在片元着色器中进行采样、光照模型计算。
- 直接在片元着色器中,将采样得到的法线方向变换到世界空间下,再进行其他计算。
在性能上来说第一种方案由于第二种。但第二种方法更具通用性,因为有时我们需要在世界空间下进行一些计算。
切线空间纹理映射
首先我们来实现第一种方案。
依然是复制一份之前的Shader,并在其基础上进行修改。
先定义两个属性,一个用于添加法线纹理,另一个用于控制凹凸程度
_BumpMap("Normal Map",2D) = "bump" {
}
_BumpScale("Bump Scale",float) = 1
然后在CG代码中声明对应的变量
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
由于我们需要在顶点着色器中将光源方向和视角方向从模型空间转换到切线空间,所以输出结构体需要进行一些修改
struct v2f
{
float4 vertex: SV_POSITION;
fixed3 lightDir: TEXCOORD0;
fixed3 viewDir: TEXCOORD1;
float2 uv:TEXCOORD2;
float2 normalUv:TEXCOORD3;
};
接下来需要将光源方向和视角方向变换到切线空间,要完成这步操作需要求出变换所需的旋转矩阵。我们已知的是变换后的三个向量: b = ( 0 , 1 , 0 ) , t = ( 1 , 0 , 0 ) , n = ( 0 , 0 , 1 ) b = (0,1,0) ,t=(1,0,0),n=(0,0,1) b=(0,1,0),t=(1,0,0),n=(0,0,1)。假设变换前的三个向量为: b ′ = ( x b , y b , z b ) , t ′ = ( x t , y t , z t ) , n ′ = ( x n , y n , z n ) b'=(x_b,y_b,z_b),t'=(x_t,y_t,z_t),n'=(x_n,y_n,z_n) b′=(xb,yb,zb),t′=(xt,yt,zt),n′=(xn,yn,zn),变换矩阵为
M = [ − c 1 − − c 2 − − c 3 − ] M = \begin{bmatrix} -c1- \\ -c2- \\ -c3- \end{bmatrix} M=⎣
⎡−c1−−c2−−c3−⎦
⎤
则存在如下关系
[ − c 1 − − c 2 − − c 3 − ] [ x b y b z b ] = [ 0 1 0 ] \begin{bmatrix} -c1- \\ -c2- \\ -c3- \end{bmatrix} \begin{bmatrix} x_b \\ y_b \\ z_b \end{bmatrix}= \begin{bmatrix} 0 \\ 1 \\ 0 \end{bmatrix} ⎣
⎡−c1−−c2−−c3−⎦
⎤⎣
⎡xbybzb⎦
⎤=⎣
⎡010⎦
⎤
[ − c 1 − − c 2 − − c 3 − ] [ x t y t z t ] = [ 1 0 0 ] \begin{bmatrix} -c1- \\ -c2- \\ -c3- \end{bmatrix} \begin{bmatrix} x_t \\ y_t \\ z_t \end{bmatrix}= \begin{bmatrix} 1 \\ 0 \\ 0 \end{bmatrix} ⎣
⎡−c1−−c2−−c3−⎦
⎤⎣
⎡xtytzt⎦
⎤=⎣
⎡100⎦
⎤
[ − c 1 − − c 2 − − c 3 − ] [ x n y n z n ] = [ 0 0 1 ] \begin{bmatrix} -c1- \\ -c2- \\ -c3- \end{bmatrix} \begin{bmatrix} x_n \\ y_n \\ z_n \end{bmatrix}= \begin{bmatrix} 0 \\ 0 \\ 1 \end{bmatrix} ⎣
⎡−c1−−c2−−c3−⎦
⎤⎣
⎡xnynzn⎦
⎤=⎣
⎡001⎦
⎤
可以求得 c 1 = t ′ , c 2 = b ′ , c 3 = n ′ c1 = t',c2=b',c3=n' c1=t′,c2=b′,c3=n′
有了变换矩阵,接下来就可以在顶点着色器中进行计算了。首先输入参数我们需要换成appdata_tan,因为它内置了切线方向。然后求出旋转矩阵,再将光源方向和视角方向变换到切线空间,存入输出参数即可
v2f vert(appdata_tan v)
{
v2f o;
// 将顶点坐标从物体空间转换到裁剪空间
o.vertex = UnityObjectToClipPos(v.vertex);
// 计算贴图纹理坐标
o.uv = TRANSFORM_TEX(v.texcoord,_MainTex);
// 计算法线纹理坐标
o.normalUv = TRANSFORM_TEX(v.texcoord,_BumpMap);
// 求副切线向量
float3 biNormal = cross(normalize(v.normal),normalize(v.tangent.xyz))*v.tangent.w;
// 求出旋转矩阵
float3x3 rotation = float3x3(v.tangent.xyz,biNormal,v.normal);
// 变换光源方向、视角方向
o.lightDir = mul(rotation,ObjSpaceLightDir(v.vertex));
o.viewDir = mul(rotation,ObjSpaceViewDir(v.vertex));
return o;
}
其中,计算旋转矩阵的过程可以直接使用Unity内置的宏。它完全等价于上面的求副切线向量,再求旋转矩阵的语句。
TANGENT_SPACE_ROTATION;
接下来在片元着色器中对法线贴图进行采样,然后计算出切线空间的法线坐标,再将各个坐标带入光照模型公式中即可
fixed4 frag(v2f i):SV_Target
{
const fixed3 tangentLightDir = normalize(i.lightDir);
const fixed3 tangentViewDir = normalize(i.viewDir);
// 法线贴图采样
fixed4 packedNormal = tex2D(_BumpMap, i.normalUv);
// 图片类型为default
// fixed3 tangentNormal;
// tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;
// tangentNormal.z = sqrt(1 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
// 图片类型为NormalMap
fixed3 tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
// 环境光
const fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
const fixed3 albedo = tex2D(_MainTex, i.uv).rgb;
// 计算漫反射
const fixed3 diffuse = _LightColor0.rgb * albedo * _Diffuse.rgb * (0.5 * dot(tangentLightDir, tangentNormal)+ 0.5);
// 计算高光反射
const fixed3 halfDir = normalize(tangentLightDir + tangentViewDir);
const fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(tangentNormal, halfDir)), _Gloss);
fixed3 color = ambient + diffuse + specular;
return fixed4(color, 1);
}
这里使用了Unity内置的UnpackNormal()
函数来获取法线方向,这是因为当图片的类型被设置为「Normal map」时,Unity会根据不同平台对纹理进行压缩。通过这个函数就可以针对不同的压缩方式对法线纹理进行正确的采样。
效果如下,左侧未进行纹理采样,右侧则进行了纹理采样。可以很明显的看到右侧比左侧更有凹凸感。
世界空间纹理映射
接下来实现第二种方案。
把上面的Shader复制一份,直接在这基础上进行修改。
首先修改一下输出参数。我们需要在顶点着色器中计算出从切线空间转换到世界空间的变换矩阵,但一个插值寄存器最多只能存储float4大小的变量,所以对于矩阵这种变量,我们需要定义三个float3类型的变量拆开存储。但为了充分利用插值寄存器的空间,我们将其定义为float4类型,多出来的一个维度可以用来存储世界空间下的顶点位置。uv变量也可以定义成float4类型,其中xy分量用来存储贴图纹理坐标,zw分量用来存储法线纹理坐标
struct v2f
{
float4 vertex: SV_POSITION;
float4 uv: TEXCOORD0;
float4 TtoW0: TEXCOORD1;
float4 TtoW1: TEXCOORD2;
float4 TtoW2: TEXCOORD3;
};
接下来在顶点着色器中计算变换矩阵,并存入输出参数中
v2f vert(appdata_tan v)
{
v2f o;
// 将顶点坐标从物体空间转换到裁剪空间
o.vertex = UnityObjectToClipPos(v.vertex);
// 计算贴图纹理坐标
o.uv.xy = TRANSFORM_TEX(v.texcoord, _MainTex);
// 计算法线纹理坐标
o.uv.zw = TRANSFORM_TEX(v.texcoord, _BumpMap);
float3 worldPos = mul(unity_ObjectToWorld,v.vertex);
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
{
const float3 worldPos = float3(i.TtoW0.w,i.TtoW1.w,i.TtoW2.w);
// 世界空间下的光线、视角坐标
const fixed3 lightDir = normalize(UnityWorldSpaceLightDir(worldPos));
const fixed3 viewDir = normalize(UnityWorldSpaceViewDir(worldPos));
// 法线贴图采样
const fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
fixed3 tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
// 将切线空间法线变换到世界空间
tangentNormal = normalize(float3(dot(i.TtoW0.xyz,tangentNormal),dot(i.TtoW1.xyz,tangentNormal),dot(i.TtoW2.xyz,tangentNormal)));
// 环境光
const fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
const fixed3 albedo = tex2D(_MainTex, i.uv).rgb;
// 计算漫反射
const fixed3 diffuse = _LightColor0.rgb * albedo * _Diffuse.rgb * (0.5 * dot(lightDir, tangentNormal)+ 0.5);
// 计算高光反射
const fixed3 halfDir = normalize(lightDir + viewDir);
const fixed3 specular = _LightColor0.rgb * _Specular.rgb * pow(saturate(dot(tangentNormal, halfDir)), _Gloss);
fixed3 color = ambient + diffuse + specular;
return fixed4(color, 1);
}
效果如下,最右侧为世界空间纹理映射
三、渐变纹理
纹理不止能用来定义一个物体的颜色,也可以用来存储任何表面的属性。一种常见的用法就是使用渐变纹理来控制漫反射光照的结果。使用一张从冷到暖的渐变图片用于纹理采样,并将采样结果用于计算漫反射模型。就可以得到一种插画风格的渲染效果,物体的轮廓线相比于传统的漫反射更加明显。很多卡通风格的渲染中都使用了这种技术。
实现渐变纹理也很简单,只需要修改漫反射计算模型即可
// 获取归一化的光源方向
const fixed3 worldLight = normalize(_WorldSpaceLightPos0);
// 计算漫反射
fixed halfLambert = 0.5*dot(i.worldNormal, worldLight)+0.5;
const fixed3 diffuse = _LightColor0.rgb * _Diffuse.rgb * tex2D(_RampTex,fixed2(halfLambert,halfLambert)).rgb;
下面是指定了不同的渐变纹理的模型效果
四、遮罩纹理
我们之前编写的高光Shader为所有像素都制定了同样大小的高光强度,但有时我们希望模型表面的某些区域高光强一些,某些区域弱一些。这就需要用一张遮罩纹理来控制光照。一般的做法是通过采样得到遮罩纹理的纹素值,然后使用其中的某个或某几个通道与表面属性相乘。当该通道的值为0时,表面就不受该属性影响,为1时就不会限制该属性对表面的影响。
遮罩纹理实现起来也很简单,这里使用了之前切线空间纹理映射的Shader进行修改。首先定义属性用来指定遮罩纹理和影响系数
_SpecularMask("Specular Mask",2D) ="white"{
}
_SpecularMaskScale("Mask Scale",float) = 1
然后在CG中定义对应的变量
sampler2D _SpecularMask;
float4 _SpecularMask_ST;
float _SpecularMaskScale;
在输出参数中定义遮罩的uv属性,然后在顶点着色器中转换uv坐标
// ...
o.maskUv = TRANSFORM_TEX(v.texcoord,_SpecularMask);
// ...
最后在片元着色器中进行遮罩纹理采样,取其中的某个通道,乘到高光模型计算公式中
// ...
// 遮罩纹理采样
const fixed3 specularMask = tex2D(_SpecularMask,i.maskUv).r * _SpecularMaskScale;
const fixed3 specular = _LightColor0.rgb * _Specular.rgb * specularMask * pow(saturate(dot(tangentNormal, halfDir)), _Gloss);
// ...
最后随便找张图片作为遮罩纹理,与没有采用遮罩纹理的对比如下