文章目录
- 凹凸映射
- 法线纹理设置
- 高度纹理(Height Map)
- 法线纹理(Normal Map)
- 模型空间的法线纹理
- 切线空间的法线纹理
- 优劣对比
- 切线空间下的法线纹理光照计算
- 最终效果
- 完整代码
- TANGENT语义
- 内置宏 TANGENT_SPACE_ROTATION
- ObjSpaceLightDir 函数
- ObjSpaceViewDir 函数
- tex2D函数
- 设置法线贴图
- UnpackNormal函数
- 法线方向的 z 分量
凹凸映射
凹凸映射的目的是使用一张纹理来修改物体表面的发现,使其在同一个面片下也能呈现凹凸的效果。这种方法并不会改变模型的顶点位置,也就是说模型的整体形状并没有改变,只是看起来有了凹凸的效果而已,当我们从侧面看这个模型的凹凸面时会发现其实那个凹凸效果是假的。
凹凸映射有两种主要方法:
- 高度纹理(Height Map):使用一张高度纹理图来模拟表面位移,然后得到一个修改后的法线值,这种方法也被称为高度映射(Height Mapping);
- 法线纹理(Normal Map):使用一张法线纹理(Normal Map)来直接存储表面法线,这种方法被称为法线映射(Normal Mapping)。
法线纹理设置
当我们在 Unity 的 Project 中选中一张图片资源后,在 Inspector 面板中会有一些选项,其中 Texture Type 选为 Normal Map 后,该图片就会被标识为法线纹理。
另外一个重要设置为 Create from Grayscale ,如果该项勾选,则会将这张图片标记为高度纹理,反之则标记为法线纹理。
当我们勾选了 Create from Grayscale 选项后,会出现两个新的修改项,其中 Bumpiness 用于控制凹凸程度,而 Filtering 则包含两个选项,其中 Smooth 使得生成后的法线纹理比较平滑,而另一个选项 Sharp 则会使用 Sobel 滤波来生成法线。
高度纹理(Height Map)
高度纹理也就是我们俗称的高度图,高度图可以用来实现凹凸映射,高度图中存储的是强度值(intensity),它用于表示模型表面局部的海拔高度。因此,颜色越浅表明该位置的表面越向外凸起,颜色越深表明该位置的表面越向内凹陷。
这种方式的好处是非常直观,一眼就能看出凹凸情况;但缺点是计算更加复杂,在实时计算时不能直接得到表面法线,需要结合像素的灰度值计算才行。
法线纹理(Normal Map)
法线纹理存储的是表面的法线方向。通常有两种方式来存储发现纹理:
- 模型空间的法线纹理(Object-Space Normal Map)
- 切线空间的法线纹理(Tangent-Space Normal Map)
如下图:左边为模型空间下的法线纹理,右边为切线空间下的法线纹理。
模型空间的法线纹理
模型空间下的法线纹理实际上就是将模型空间下的法线方向存储到一张纹理图中,单个纹素的数据格式是 float3 ,对应的三个分量分别为:
- float3的第一个分量:代表了法线向量在模型空间X轴方向上的分量。
- float3的第二个分量:代表了法线向量在模型空间Y轴方向上的分量。
- float3的第三个分量:代表了法线向量在模型空间Z轴方向上的分量。
因为这三个分量范围在 [ -1, 1 ] 之间,而像素的分量范围是 [ 0, 1 ] ,所以存储和使用模型空间的法线纹理时需要一部映射操作:
- 在存储模型空间的法线纹理时,需要将法线转换为纹素,公式为: p i x e l = n o r m a l + 1 2 pixel = \frac{normal + 1}{2} pixel=2normal+1;
- 在解析法线纹理获得纹素后,需要通过 n o r m a l = p i x e l ∗ 2 − 1 normal = pixel * 2 - 1 normal=pixel∗2−1 的方式将纹素映射到法线方向上来。
注意,模型空间下的法线纹理虽然相对直观,但是由于提供的是模型空间下的法线方向,通常使用起来需要进行一次换算操作,相对来说性能会差一些,所以通常开发者会使用另外一种法线纹理方式,即切线空间的法线纹理。
模型空间下的法线纹理图示例如下:
切线空间的法线纹理
切线空间下的法线纹理使用的不是模型空间坐标系,而是将每个顶点作为新的坐标系(也可以称之为切线空间 Tangent Space),在切线空间坐标系下描述当前位置的法线方向。
为什么能够使用这样的方式进行存储呢?这样的存储方式是否具有唯一性呢?首先我们要了解一下什么是切线空间。模型的每个顶点都有一个属于自己的切线空间,这个切线空间的原点就是该顶点本身,它的 z 轴是顶点的法线方向,x 轴是顶点的切线方向,y 轴由法线和切线叉积获得,也被称之为 副切线(bitangent, b) 或副法线。而关于唯一性的问题,我们想到的可能是在同一个面上有多个顶点,如何能保证某一点上的法线是使用哪个顶点作为原点的呢,其实这个问题比较简单,因为只要是同一平面的顶点,其法线方向都是一致的,所以不会存在数据表述不唯一的情况。
切线空间下的法线纹理通常为蓝色的图片,比如一个这样的石头对应的法线图是下面这样的:
为什么模型空间下的法线纹理是五颜六色的,而切线空间下的法线纹理却是以蓝色为主的呢?
这是因为模型空间下的法线方向是使用同一个坐标空间(物体的模型空间)进行计算的,所以不同法线的朝向方向也是完全不同的,即便通过映射会少去一半的颜色,但仍然具有很多种颜色。而切线空间下的法线纹理中每个点存储的法线方向都是以当前顶点的方向为基准的,所以在存储时等同于大家都是以当前点所对应的法线方向为基准的,也就是说如果这个面是平的,那么这个面上的所有法线方向都将是 (0, 0, 1) ,换算成纹素的值就是 RGB (0.5, 0.5, 1) ,刚好是浅蓝色,所以在没有极特殊情况的话,通常法线方向对应的颜色值都与蓝色相近。
优劣对比
模型空间下的法线纹理有以下优势:
- 实现简单,更加直观。生成纹理的计算方式较为简单;
- 在纹理坐标的缝合处和尖锐的边角部分,可见的突变(缝隙)较少,即可以提供平滑的边界;
切线空间下的法线纹理具有更多优势:
- 自由度很高。模型空间下的法线纹理记录的是绝对法线信息,几乎可以说只能给当前模型使用,无法更换模型,而切线纹理记录的是相对信息,也就是说即便换一个其他模型也能得到一个相对合理的结果;
- 可以进行 UV 动画。比如我们最常见的水波效果,通常可以通过移动纹理的 UV 坐标来实现;
- 可以重用法线纹理。比如一个砖块只需要一张法线纹理就可以用到所有的 6 个面上;
- 可压缩。由于切线空间下的法线纹理中 z 方向总是正方向,因此我们可以只记录 x 和 y 两个方向,通过推导得到 z 方向。而模型空间下的纹理三个分量都是有用的,无法进行压缩。
切线空间下的法线纹理光照计算
最终效果
如果有需要纹理图和法线图的可以私信我,我发给你。但实际上这种东西完全可以在网站上去下载,只要是能用的就可以。
完整代码
// 在切线空间下计算法线纹理的光照
Shader "Y7Play/Chapter7/Normal Map In Tangent Space"
{Properties{// 主颜色,该颜色用于跟纹理及光照颜色相乘得到最终颜色_Color ("Color", Color) = (1,1,1,1)// 主纹理_MainTex ("Main Tex", 2D) = "white" {}// 法线纹理(Bump:凹凸)_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.cginc"fixed4 _Color;sampler2D _MainTex;float4 _MainTex_ST;sampler2D _BumpMap;fixed4 _BumpMap_ST;float _BumpScale;fixed4 _Specular;float _Gloss;struct a2v{float4 vertex : POSITION;float3 normal : NORMAL;float4 tangent : TANGENT;float4 texcoord : TEXCOORD0;};struct v2f{float4 pos : SV_POSITION;float4 uv : TEXCOORD0;float3 lightDir : TEXCOORD1;float3 viewDir : TEXCOORD2;};// 顶点着色器负责将光源方向和观察方向从世界空间变换到切线空间v2f vert(a2v v){v2f o;o.pos = UnityObjectToClipPos(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 vector from object space to tangent space// 构造一个矩阵,将向量从对象空间变换到切线空间float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);// Or just use the built-in function to do the same thing// 或者直接使用内置函数,这一行代码等于前面的两行代码binormal、rotation,// 最终会生成一个rotation对象,用于将顶点法线、副法线和观察方向从对象空间变换到切线空间// 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;}// 片元着色器用于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.wy * 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// 或者将纹理标记为 “Normal map”,并使用内置函数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);}ENDCG}}FallBack "Specular"
}
TANGENT语义
TANGENT 语义用于把顶点的切线方向填充到 tangent 变量中,其类型为 float4 ,其中前面的xyz用于表明顶点的切线方向,而 w 则用于标明副切线的方向朝向正面还是反面,由于顶点的切线方向和法线方向已经有了,所以不需要使用xyz三个属性来表示副切线的方向,只需要说明副切线使用左手坐标系还是右手坐标系即可。通常这个 w 的值为 +1 或 -1 :
- 如果 TANGENT.w 为 +1,则副切线向量通常按照某种约定的右手系规则来确定方向。
- 如果 TANGENT.w 为 -1,则副切线向量按照相反的左手系规则来确定方向。
对于左手和右手坐标系不清楚的可以查看我的另外一篇文章:【Unity】Unity 几何知识、弧度、三角函数、向量运算、点乘、叉乘
内置宏 TANGENT_SPACE_ROTATION
内置宏 TANGENT_SPACE_ROTATION用于获取从模型空间到切线空间的变换矩阵 rotation ,如果不使用宏的代码为:
// Compute the binormal
// 计算副法线(副切线)
// 顶点法线向量与顶点的切向量的叉积,得到副切线,再乘以纹理的切线方向的符号
float3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;// Construct a matrix which transform vector from object space to tangent space
// 构造一个矩阵,将向量从对象空间变换到切线空间
float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);
如果使用了内置宏就变成了:
// 或者直接使用内置函数,这一行代码等于前面的两行代码binormal、rotation,
// 最终会生成一个rotation对象,用于将顶点法线、副法线和观察方向从对象空间变换到切线空间
TANGENT_SPACE_ROTATION;
最终会生成一个rotation对象,即从模型空间到切线空间的变换矩阵。
ObjSpaceLightDir 函数
获取模型空间下的光源方向。
ObjSpaceViewDir 函数
获取模型空间下的观察方向。
tex2D函数
tex2D函数用于从纹理图中获取对应位置的纹素(也可以理解为颜色),比如,当我想从主纹理 _MainTex 中获取纹素时就可以使用 tex2D 函数,代码如下:
fixed3 color = tex2D(_MainTex, i.uv).rgb;
但通常我们会使用一个颜色与之相乘以得到反照率(albedo)的值,代码如下:
fixed3 albedo = tex2D(_MainTex, i.uv).rgb * _Color.rgb;
如果要从法线图中获取纹素也可以:
fixed4 packedNormal = tex2D(_BumpMap, i.uv.zw);
设置法线贴图
正常情况下,我们需要将法线贴图的纹理格式 Texture Type 设置为 Normal Map ,如下图:
在前面章节【模型空间的法线纹理】中我们提到法线图中存储的格式与实际上的法线方向不一样,需要进行一次映射( n o r m a l = p i x e l ∗ 2 − 1 normal = pixel * 2 - 1 normal=pixel∗2−1),如果已经设置为法线图,可以通过以下方式获取真正的法线方向:
// If the texture is not marked as "Normal map"
// 如果纹理没有被标记为“法线贴图”
tangentNormal.xy = (packedNormal.wy * 2 - 1) * _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
如果不想设置,需要在 Shader 中做如下处理,这种方式就可以获取到映射后的法线方向了:
// Or mark the texture as "Normal map", and use the built-in function
// 或者将纹理标记为“Normal map”,并使用内置函数
tangentNormal = UnpackNormal(packedNormal);
tangentNormal.xy *= _BumpScale;
tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));
UnpackNormal函数
其中 UnpackNormal 为Unity提供的内置函数,函数通常用于解码存储在RGBA通道中的法线信息。通过这行代码tangentNormal = UnpackNormal(packedNormal);
,UnpackNormal 函数已经将一个完整的法线信息赋值给了 tangentNormal 变量,然后我们再让 tangentNormal 的 xy *= _BumpScale ,就得到了最终的法线方向 xy。
UnpackNormal 函数在 UnityCG.cginc 中的实现代码如下:
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; // 没有压缩的直接计算结果
#elsereturn UnpackNormalDXT5nm(packednormal); // 压缩过的分别计算xyz的结果
#endif
}
根据上述代码不难看出,Unity 内部通过 UnpackNormal 函数获取法线信息时会根据不同的运行环境进行不同的处理,因为有些平台使用 DXT5nm 格式对法线纹理进行了压缩。在 DXT5nm 格式的法线纹理中,纹素的 a 通道(即 w 分量)对应了法线的 x 分量,g 通道对应了法线的 y 分量,而纹理的 r 和 b 通道则会被舍弃,法线的 z 分量可以由 xy 分量推导获得。
法线方向的 z 分量
法线方向的 z 分量计算比较复杂,先使用 dot(tangentNormal.xy, tangentNormal.xy) 获得 tangentNormal.xy 与自身的点积,其结果为 tangentNormal.xy 向量长度的平方。
然后通过 saturate 函数(饱和函数)将前面点积的结果控制在 0 到 1 之间,这是为了确保即使由于数值误差导致点积结果大于1,我们也能得到一个有效的输入来计算平方根。然而,在理想情况下(即没有数值误差时),dot(tangentNormal.xy, tangentNormal.xy) 应该已经是一个介于0和1之间的值,因为tangentNormal是一个单位向量。但saturate的使用提供了一层额外的保护,以防万一。
最后,通过计算1.0减去点积(经过饱和处理)的结果的平方根来得到 z 分量。这个计算基于单位向量的性质: x 2 + y 2 + z 2 = 1 x^2 + y^2 + z^2 = 1 x2+y2+z2=1。由于我们已经知道了x和y的值,可以通过这个公式来求解z。
更多内容请查看总目录【Unity】Unity学习笔记目录整理