Unity系列文章目录
文章目录
前言
纹理的另一种常见的应用就是凹凸映射(bump mapping)。凹凸映射的目的是使用一张纹理
来修改模型表面的法线,以便为模型提供更多的细节。这种方法不会真的改变模型的顶点位置,
只是让模型看起来好像是“凹凸不平”的,但可以从模型的轮廓处看出“破绽”。
有两种主要的方法可以用来进行凹凸映射:一种方法是使用一张高度纹理(height map)来
模拟表面位移(displacement),然后得到一个修改后的法线值,这种方法也被称为高度映射(height
mapping);另一种方法则是使用一张法线纹理(normal map)来直接存储表面法线,这种方法
又被称为法线映射(normal mapping)。尽管我们常常将凹凸映射和法线映射当成是相同的技术,
但读者需要知道它们之间的不同。
7.2.1 高度纹理
我们首先来看第一种技术,即使用一张高度图来实现凹凸
映射。高度图中存储的是强度值(intensity),它用于表示模型
表面局部的海拔高度。因此,颜色越浅表明该位置的表面越向
外凸起,而颜色越深表明该位置越向里凹。这种方法的好处是
非常直观,我们可以从高度图中明确地知道一个模型表面的凹
凸情况,但缺点是计算更加复杂,在实时计算时不能直接得到
表面法线,而是需要由像素的灰度值计算而得,因此需要消耗
更多的性能。图7.11 给出了一张高度图。
高度图通常会和法线映射一起使用,用于给出表面凹凸的
额外信息。也就是说,我们通常会使用法线映射来修改光照。
7.2.2 法线纹理
而法线纹理中存储的就是表面的法线方向。由于法线方向的分量范围在[−1, 1],而像素的分
量范围为[0, 1],因此我们需要做一个映射,通常使用的映射就是:
1
2
pixel normal
这就要求,我们在Shader 中对法线纹理进行纹理采样后,还需要对结果进行一次反映射的过
程,以得到原先的法线方向。反映射的过程实际就是使用上面映射函数的逆函数:
normal = pixel × 2 − 1
然而,由于方向是相对于坐标空间来说的,那么法线纹理中存储的法线方向在哪个坐标空间中
呢?对于模型顶点自带的法线,它们是定义在模型空间中的,因此一种直接的想法就是将修改后的
模型空间中的表面法线存储在一张纹理中,这种纹理被称为是模型空间的法线纹理(object-space
normal map)。然而,在实际制作中,我们往往会采用另一种坐标空间,即模型顶点的切线空间
(tangent space)来存储法线。对于模型的每个顶点,它都有一个属于自己的切线空间,这个切线
空间的原点就是该顶点本身,而z 轴是顶点的法线方向(n),x 轴是顶点的切线方向(t),而y 轴
可由法线和切线叉积而得,也被称为是副切线(bitangent,b)或副法线,如图7.12 所示。
这种纹理被称为是切线空间的法线纹理(tangent-space normal map)。图7.13 分别给出了模
型空间和切线空间下的法线纹理(图片来源:http://www.surlybird.com/tutorials/TangentSpace/)。
从图7.13 中可以看出,模型空间下的法线纹理看起来是“五颜六色”的。这是因为所有法线
所在的坐标空间是同一个坐标空间,即模型空间,而每个点存储的法线方向是各异的,有的是(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)浅蓝色。而这个颜色就是法线纹理中大片的蓝色。这些蓝色
实际上说明顶点的大部分法线是和模型本身法线一样的,不需要改变。
▲图7.12 模型顶点的切线空间。其中, ▲图7.13 左边:模型空间下的法线纹理。
原点对应了顶点坐标,x 轴是切线方向(t), 右边:切线空间下的法线纹理
y 轴是副切线方向(b),z 轴是法线方向(n)
总体来说,模型空间下的法线纹理更符合人类的直观认识,而且法线纹理本身也很直观,容
易调整,因为不同的法线方向就代表了不同的颜色。但美术人员往往更喜欢使用切线空间下的法
线纹理。那么,为什么他们更偏好使用这个看起来“很蹩脚”的切线空间呢?
实际上,法线本身存储在哪个坐标系中都是可以的,我们甚至可以选择存储在世界空间下。
但问题是,我们并不是单纯地想要得到法线,后续的光照计算才是我们的目的。而选择哪个坐标
系意味着我们需要把不同信息转换到相应的坐标系中。例如,如果选择了切线空间,我们需要把
从法线纹理中得到的法线方向从切线空间转换到世界空间(或其他空间)中。
总体来说,使用模型空间来存储法线的优点如下。
实现简单,更加直观。我们甚至都不需要模型原始的法线和切线等信息,也就是说,计算
更少。生成它也非常简单,而如果要生成切线空间下的法线纹理,由于模型的切线一般是
和UV 方向相同,因此想要得到效果比较好的法线映射就要求纹理映射也是连续的。
在纹理坐标的缝合处和尖锐的边角部分,可见的突变(缝隙)较少,即可以提供平滑的边
界。这是因为模型空间下的法线纹理存储的是同一坐标系下的法线信息,因此在边界处通
过插值得到的法线可以平滑变换。而切线空间下的法线纹理中的法线信息是依靠纹理坐标
的方向得到的结果,可能会在边缘处或尖锐的部分造成更多可见的缝合迹象。
但使用切线空间有更多优点。
自由度很高。模型空间下的法线纹理记录的是绝对法线信息,仅可用于创建它时的那个模型,
而应用到其他模型上效果就完全错误了。而切线空间下的法线纹理记录的是相对法线信息,
这意味着,即便把该纹理应用到一个完全不同的网格上,也可以得到一个合理的结果。
可进行UV 动画。比如,我们可以移动一个纹理的UV 坐标来实现一个凹凸移动的效果,
但使用模型空间下的法线纹理会得到完全错误的结果。原因同上。这种UV 动画在水或者
火山熔岩这种类型的物体上会经常用到。
可以重用法线纹理。比如,一个砖块,我们仅使用一张法线纹理就可以用到所有的6 个面
上。原因同上。
可压缩。由于切线空间下的法线纹理中法线的Z 方向总是正方向,因此我们可以仅存储
XY 方向,而推导得到Z 方向。而模型空间下的法线纹理由于每个方向都是可能的,因此
必须存储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)新建一个材质。在本书资源中,该材质名为NormalMapTangentSpaceMat。
(3)新建一个Unity Shader。在本书资源中,该Unity Shader 名为Chapter7-把新的Unity Shader 赋给第2 步中创建的材质。NormalMapTangentSpace。
(4)在场景中创建一个胶囊体,并把第2 步中的材质赋给该胶囊体。
(5)保存场景。
打开新建的Chapter7-NormalMapTangentSpace,删除所有已有代码,并进行如下修改。
(1)首先,我们为该Unity Shader 定义一个名字:
Sh ader “Unity Shaders Book/Chapter 7/Normal Map In Tangent Space” {
(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
_Specular (“Specular”, Color) = (1, 1, 1, 1)
_Gloss (“Gloss”, Range(8.0, 256)) = 20
}
对于法线纹理_BumpMap,我们使用"bump"作为它的默认值。"bump"是Unity 内置的法线纹
理,当没有提供任何法线纹理时,"bump"就对应了模型自带的法线信息。_BumpScale 则是用于控
制凹凸程度的,当它为0 时,意味着该法线纹理不会对光照产生任何影响。
(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
#p ragma fragment frag
(5)为了使用Unity 内置的一些变量,如_LightColor0,还需要包含进Unity 的内置文件
Lighting.cginc:
#i nclude “Lighting.cginc”
(6)为了和Properties 语义块中的属性建立联系,我们在Cg 代码块中声明了和上述属性类型
匹配的变量:
fixed4 _Color;
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
float _BumpScale;
fixed4 _Specular;
fl oat _Gloss;
为了得到该纹理的属性(平铺和偏移系数),我们为_MainTex 和_BumpMap 定义了
_MainTex_ST 和_BumpMap_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 分量可以由tangentNormal.xy 计算而得。由于我们使用的是切线空间下的法
线纹理,因此可以保证法线方向的z 分量为正。 在Unity 中,为了方便Unity 对法线纹理的存储
进行优化,我们通常会把法线纹理的纹理类型标识成Normal map,Unity 会根据平台来选择不同
的压缩方法。这时,如果我们再使用上面的方法来计算就会得到错误的结果,因为此时_BumpMap
的rgb 分量并不再是切线空间下法线方向的xyz 值了。在7.2.4 节中,我们会具体解释。在这种情
况下,我们可以使用Unity 的内置函数UnpackNormal 来得到正确的法线方向。
(11)最后,我们为该Unity Shader 设置合适的Fallback:
Fa llback “Specular”
保存后返回Unity 中查看。在NormalMapTangentSpaceMat 的面板上,我们使用本书资源中的
Brick_Diffuse.jpg 和Brick_Normal.jpg 纹理对其赋值。我们可以调整材质面板中的Bump Scale 属
性来改变模型的凹凸程度。图7.15 给出了不同的Bump Scale 属性值下得到的结果。
参考
Unity Shader入门精要
冯乐乐