深入理解法线贴图

高度图转法线

高度图中保存的是物体表面的高度信息,可以利用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方向高度变化程度的向量。即法线可以写成这样的形式:
N = ( d u , 1 , d v ) \boldsymbol N = (du, 1, dv) N=(du,1,dv)
在TBN空间中,则为:
N = ( d u , d v , 1 ) \boldsymbol N = (du, dv, 1) N=(du,dv,1)
我们希望法线叠加,就是把uv方向高度变化的量进行叠加。假设从两张法线贴图中取出的法线分别为M和D,那么可得到:
M = ( m x , m y , m z ) = ( m x m z , m y m z , 1 ) D = ( d x , d y , d z ) = ( d x d z , d y d z , 1 ) \boldsymbol M = (m_x, m_y, m_z) = (\dfrac{m_x}{m_z}, \dfrac{m_y}{m_z}, 1) \\ \boldsymbol D = (d_x, d_y, d_z) = (\dfrac{d_x}{d_z}, \dfrac{d_y}{d_z}, 1) \\ M=(mx​,my​,mz​)=(mz​mx​​,mz​my​​,1)D=(dx​,dy​,dz​)=(dz​dx​​,dz​dy​​,1)
那么,最终叠加的法线N为:
N = ( m x m z + d x d z , m y m z + d y d z , 1 ) = ( m x ⋅ d z + d x ⋅ m z , m y ⋅ d z + d y ⋅ m z , m z ⋅ d z ) \boldsymbol N = (\dfrac{m_x}{m_z} + \dfrac{d_x}{d_z}, \dfrac{m_y}{m_z} + \dfrac{d_y}{d_z}, 1) = (m_x \cdot d_z + d_x \cdot m_z, m_y \cdot d_z + d_y \cdot m_z, m_z \cdot d_z) N=(mz​mx​​+dz​dx​​,mz​my​​+dz​dy​​,1)=(mx​⋅dz​+dx​⋅mz​,my​⋅dz​+dy​⋅mz​,mz​⋅dz​)
可以看出,M和D的xy分量还是会受到各自z分量的影响,那么直接去掉它:
N = ( m x + d x , m y + d y , m z ⋅ d z ) N = (m_x + d_x, m_y + d_y, m_z \cdot d_z) N=(mx​+dx​,my​+dy​,mz​⋅dz​)
这个就是最终得到的叠加法线。

当然,我们直接可以使用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:

深入理解法线贴图

如果你觉得我的文章有帮助,欢迎关注我的微信公众号(大龄社畜的游戏开发之路-

深入理解法线贴图

上一篇:深入理解法线贴图


下一篇:关于 MySQL 中对数据库表名大小写敏感的问题