本文考虑多个法线的线性混合,首先需要了解如何正确缩放法线,然后混合要怎么做。同时结合了Unity中的实现。
比如有两个法线贴图,或者采样同一个法线贴图的不同位置,现在需要将这两个法线混合。这个混合应该具有这样的性质,当一个切线法线是(0,0,1)(垂直向上)时另一个应该不受影响。
基于这样的考虑,显然不应该做这样的混合:
float3 normal = normalize(normalA * scaleA + normalB * scaleB);
基于法线贴图
我们应该回到法线的定义上来:法线实际上定义了该点处的偏导数,假设切线空间中的一个曲面Z=f(x,y),该点上的法线其实是:
将法线转成法线贴图是这样的过程:
提取法线贴图其实就是逆向得到N=normalize(n),后文都用大N代表这个值。
两个法线贴图混合,实际上代表在该点上值的相加。假设切线空间两个法线对应的曲面分别是Z1=f1(x,y)和Z2=f2(x,y),最终值Z=f1(x,y)+f2(x,y),根据偏导数加法原理,合并后的偏导数是两个函数各自偏导数的和,也就是:
我们应该要知道两个贴图中的fx'和fy',但是我们现在只有N,要转换回去的话,就是进行缩放,并且因为缩放后的z为1,所以可以这样转换:
综上可以得到最终的混合式:
计算x和y时,N1.z和N2.z其实扮演了混合系数的作用,有时可以假设这两个值都为1,这样计算很简单,副作用是对原来的法线做了适当的放大,这个方法名字叫whiteout blending。
Unity中也定义了这个函数:
half3 BlendNormals(half3 n1, half3 n2)
{
return normalize(half3(n1.xy + n2.xy, n1.z*n2.z));
}
以上都是简单的1+1混合平均,有时我们想要按系数混合,比如混合系数a和b,根据前面的推导有:
基于偏导数贴图
也可以使用另一种方法,直接存储偏导数,也就是f'x和f'y,此时贴图存储的值是:
好处是可以直接得到偏导数,并且可以做线性运算。同样假设混合系数a和b,
注意由于直接存的是偏导数,求法线时要加上负号。
法线缩放
理论分析
从定义上说,法线缩放也是一种线性运算,也必须在偏导数上使用。但在实际中经常是在法线数值N(x,y,z)的基础上直接缩放xy。
从本质上说,这样的缩放是有误差的。不妨从N(x,y,z)出发,缩放系数为k,那么先将xy缩放,然后归一化的操作表示为:
将上面的向量转化为偏导数:
很显然上式中(x,y)和k并不是线性关系,这意味着这种操作对应到偏导数不是线性的。
有一个修正的方法,是先归一化,然后缩放xy,这个操作表示为:
这样再求偏导数:
显然和k成线性关系。
实践
参考Unity中的实现,有两个相关方法计算:UnpackScaleNormalRGorAG和UnpackNormalAG。UnpackScaleNormalRGorAG是先对xy缩放,然后计算z:
half3 UnpackScaleNormalRGorAG(half4 packednormal, half bumpScale)
{
// 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;
}
UnpackNormalAG是先计算z,然后对xy缩放:
real3 UnpackNormalAG(real4 packedNormal, real scale = 1.0)
{
real3 normal;
normal.xy = packedNormal.ag * 2.0 - 1.0;
normal.z = max(1.0e-16, sqrt(1.0 - saturate(dot(normal.xy, normal.xy))));
// must scale after reconstruction of normal.z which also
// mirrors UnpackNormalRGB(). This does imply normal is not returned
// as a unit length vector but doesn't need it since it will get normalized after TBN transformation.
// If we ever need to blend contributions with built-in shaders for URP
// then we should consider using UnpackDerivativeNormalAG() instead like
// HDRP does since derivatives do not use renormalization and unlike tangent space
// normals allow you to blend, accumulate and scale contributions correctly.
normal.xy *= scale;
return normal;
}
这对应了我们前面提到的缩放的两种方法,为了得到线性的法线缩放,推荐使用第二种。但是需要特别注意的是,第二种得到的结果是没有归一化的。但是一般也不需要归一化,因为用TBN矩阵计算后都是要统一归一化一遍的,前面这步就可以省了。