深入理解法线贴图
高度图转法线
高度图中保存的是物体表面的高度信息,可以利用u,v方向上高度变化的斜率,计算出tangent和binormal,然后通过向量叉乘得到normal。我们在fragment shader中计算每个fragment的normal:
void InitializeFragmentNormal(inout Interpolators i) {
// 取两侧点进行采样
float2 du = float2(_HeightMap_TexelSize.x * 0.5, 0);
float u1 = tex2D(_HeightMap, i.uv - du);
float u2 = tex2D(_HeightMap, i.uv + du);
float2 dv = float2(0, _HeightMap_TexelSize.y * 0.5);
float v1 = tex2D(_HeightMap, i.uv - dv);
float v2 = tex2D(_HeightMap, i.uv + dv);
float3 tangent = float3(_HeightMap_TexelSize.x, u2 - u1, 0);
float3 binormal = float3(0, v2 - v1, _HeightMap_TexelSize.y);
// 注意是B x T
i.normal = cross(binormal, tangent);
i.normal = normalize(i.normal);
}
可以看到,得到的法线非常锐利,这是因为叉乘得到的原始法线为float3(_HeightMap_TexelSize.y * (u1 - u2), _HeightMap_TexelSize.x * _HeightMap_TexelSize.y, _HeightMap_TexelSize.x * (v1 - v2))
。原始法线的y分量过小,导致归一化时x和z方向的值会偏大,从而偏离(0,1,0),而显得效果十分锐利。这里我们可以特殊处理,将得到的tangent和binormal向量先进行缩放,再进行叉乘计算:
float3 tangent = float3(1, u2 - u1, 0);
float3 binormal = float3(0, v2 - v1, 1);
法线贴图采样
在Unity中,高度图可以直接导入成法线贴图,只要在导入设置中进行修改即可:
我们可以使用现成的API函数UnpackScaleNormal
提取法线贴图中的normal:
half3 UnpackScaleNormal(half4 packednormal, half bumpScale)
{
return UnpackScaleNormalRGorAG(packednormal, bumpScale);
}
half3 UnpackScaleNormalRGorAG(half4 packednormal, half bumpScale)
{
#if defined(UNITY_NO_DXT5nm)
half3 normal = packednormal.xyz * 2 - 1;
#if (SHADER_TARGET >= 30)
// SM2.0: instruction count limitation
// SM2.0: normal scaler is not supported
normal.xy *= bumpScale;
#endif
return normal;
#else
// This do the trick
packednormal.x *= packednormal.w;
half3 normal;
normal.xy = (packednormal.xy * 2 - 1);
#if (SHADER_TARGET >= 30)
// SM2.0: instruction count limitation
// SM2.0: normal scaler is not supported
normal.xy *= bumpScale;
#endif
normal.z = sqrt(1.0 - saturate(dot(normal.xy, normal.xy)));
return normal;
#endif
}
可以看到,如果引擎编译shader时发现平台不支持DXT5NM,则会将纹理信息直接按rgb格式解析为法线。bumpScale参数用法就和高度图的时候类似,用来缩放法线的xy分量来调整凹凸的程度。如果支持DXT5NM,那么法线贴图里只用了g通道和a通道来储存法线的y分量和x分量。z分量需要根据向量的归一化手动计算。另外别忘了,这里得到的法线是基于TBN空间的,如果直接拿来用,还需要手动调换一下y分量和z分量的位置:
i.normal = UnpackScaleNormal(tex2D(_NormalMap, i.uv), _BumpScale);
i.normal = i.normal.xzy;
i.normal = normalize(i.normal);
多张法线贴图
之前我们提到过detail texture,可以与main texture叠加来丰富纹理细节。类似地,我们可以拥有一张detail normal map,与原来的法线贴图进行叠加。normal map在unity导入时也可以设置fade range,完全淡出时的效果就跟没有法线一样。
那么,怎样对两个法线进行叠加呢?显然,直接加和求平均是不合适的,平均会抵消法线的信息,使得效果变得平整。例如一个法线n1=(0, 1, 0),另外一个法线n2=(0, 0.5, 0.87),平均之后得到的法线n3=(0, 0.75, 0.44),显然与竖直方向更加接近了,这不是我们想要的。我们希望,当有一个法线的效果是完全平整时,也不会影响另外一个法线产生的效果。
让我们回到之前的高度图中来。我们知道,法线其实是反映高度在uv方向高度变化程度的向量。即法线可以写成这样的形式:
在TBN空间中,则为:
我们希望法线叠加,就是把uv方向高度变化的量进行叠加。假设从两张法线贴图中取出的法线分别为M和D,那么可得到:
那么,最终叠加的法线N为:
可以看出,M和D的xy分量还是会受到各自z分量的影响,那么直接去掉它:
这个就是最终得到的叠加法线。
当然,我们直接可以使用Unity提供的API函数BlendNormals
来进行这个操作:
half3 BlendNormals(half3 n1, half3 n2)
{
return normalize(half3(n1.xy + n2.xy, n1.z*n2.z));
}
切线空间
在使用Unity导入模型时,通常使用MikkTSpace算法来计算切线。MikkTSpace约定了计算binormal的方式为:
binormal = cross(normal.xyz, tangent.xyz) * tangent.w;
可以发现tangent向量是4维的,其中w分量的值为+1/-1。那么这个w分量是做什么用的呢?
我们知道,tangent和binormal实际代表了纹理的uv方向。在DirectX和OpenGL平台上,纹理的u方向是一致的,都是从左向右;而v方向却有差别,DirectX上v方向是自顶向下的,原点在左上方;OpenGL上v方向是自底向上的,原点在右下方。因此,为了保证binormal的方向始终与纹理的v方向保持一致,需要引入一个分量w来控制是否翻转binormal。
此外,如果是镜像模型,那么模型的法线和切线应当是对称的,但binormal应当还是一致的,即模型两侧的TBN空间不是一致的,而是对称的。这时,两边的tangent的w分量就需要不同了。来看一个例子:
图中是一个镜像模型,让我们导入到Unity中,看看它两边的TBN长啥样:
其中,红色代表tangent,蓝色代表binormal,绿色代表normal。让我们拉近了来看下:
可以看到,两边的TBN空间是对称的,为了实现这一点,需要借助tangent的w分量。
不过在Unity中,我们发现实际计算binormal的方法是这样的:
float3 CreateBinormal (float3 normal, float3 tangent, float binormalSign) {
return cross(normal, tangent.xyz) *
(binormalSign * unity_WorldTransformParams.w);
}
这里多出了一个变量unity_WorldTransformParams
。它的w分量与物体transform的scale有关。如果有奇数个scale的值为负数,那么w取值为-1,否则取值为0。其实就是说,在scale为负数的时候,物体的纹理可能会被翻转,导致TBN空间不对,这和前面提到的镜像问题原因类似。来看一个例子:
当scale.x为-1时,原本向上的法线实际上要变得向下,在tangent不变的情况下,需要翻转binormal:
当scale.x和scale.z都为-1时,原本向上的法线经过两次翻转之后依旧向上,就无需翻转binormal:
如果你觉得我的文章有帮助,欢迎关注我的微信公众号(大龄社畜的游戏开发之路)-