法线贴图

发布时间 2023-03-24 09:09:29作者: 爱莉希雅

前言

​ 本篇将介绍什么是法线贴图(normal map),为什么需要它,它是如何实现的,切线空间及从切线空间和世界空间间的变换

什么是法线贴图

​ 法线贴图也属于纹理贴图,它是基于凹凸贴图衍生出来的,只不过纹理贴图中的纹素是RGB颜色值,而法线贴图中的纹素是法向量的坐标

如图所示,TBN坐标系(稍后讲解)中的细箭头即为存储的法线
image-20230323150127342

为什么需要法线贴图呢?

  • 动机:法线的用途是计算光照,在纹理图中存储法向量,再将其带入光照计算,这看上去没有什么优势啊?事实上,这种方法有很强大的优化效果。想象一个场景,我们需要物体有真实的凹凸感,而凹凸感是光照体现的,那么我们需要法线密度高且它们的方向并不大致相同。第一想法是我们可以通过高模建模,在计算法向量时每个面的法线方向不同,由于各个面的光照计算结果不同且面非常多,这肯定是会产生凹凸感。但该方法开销是很大很大的,因此我们需要想一种方法——既能表现真实的凹凸感,也能大大降低开销,没错这种方法就是法线贴图

  • 法线贴图的实现原理:法线贴图保存高模的法线信息提供给低模使用,这样低模就相当于有和高模那样的法线,在后续过程中计算法向量就相当于是计算高模的法向量

  • 普通的纹理贴图不能实现吗?也许有小伙伴会问,直接给物体贴一个自带凹凸感的贴图不可以吗?这种方式虽然有一定的凹凸感,但不真实,很容易被识破。这是因为纹理下的网格过于平滑,仅仅将带有凹凸感的纹理绘制到光滑的柱面是不够的,因为光照是基于网格三角形的而非纹理图片

    如下两张图,第一张图是普通的纹理贴图,可以看到确实很假,像一张贴图;第二张图是法线贴图,可以看到该物体就具有真实的凹凸感
    image-20230323210401511image-20230323210446830

压缩和存储法线图

  • 法向量转换为图像格式:由于纹理图的图像格式范围一般是[0,255],而归一化法向量的范围是[-1,1],因此将法向量存于纹理图中需要进行一定的变换,公式如下:\(\large f(x) = (0.5x + 0.5) * 255\)

  • 图像格式转换为法向量:\(\large f^{-1}(x) = \frac{2x}{255} - 1\)

  • 存储法线图:若用压缩纹理的格式来存储法线图,BC7(DXGI_FORMAT_BC7_UNORM)图像格式的质量最佳,它大大减少了因压缩法线图而带来的误差

采样法线图

对法线图进行采样的方法如下:

float3 normalT = gNormalMap.Sample( gTriLinearSam, pin.Tex );

此时已经获取[0,1]范围内的坐标,但坐标的解压工作并没有完成,我们还需要将[0,1]范围内的坐标变化至[-1,1],该变换公式如下:\(\large g(x) = 2x - 1\)

normalT = 2.0f*normalT - 1.0f;

切线空间

  • 定义:切线空间是一个3d纹理坐标系,xy轴分别对应uv轴,z轴为法向量,且这三条轴两两相切,但我们将这xyz轴称作TBN轴,我们称T轴为切线(tangent)、B轴为副切线( binormal)、N轴为法线.该空间针对特定三角形
    image-20230323215040762

  • TBN矩阵?我们需要将纹理映射至3D三角形上,而一般来说计算光照是在世界空间中进行计算,但法线图中的法向量存在于切线空间中,因此TBN矩阵定义了将纹理从切线空间转换至世界空间的过程。TBN矩阵仅仅在意向量的变化,因此这是个\(3\times3\)的矩阵
    image-20230323213732326

  • 从uv空间到局部空间的推导

    如上图所示,设纹理坐标分别为\(\large (u_0, v_0),(u_1, v_1),(u_2, v_2)\)的顶点\(\large v_0, v_1, v_2\)在切线空间中定义了一个纹理三角形,\(\large e_0 = v_1 - v_0\),\(\large e_1 = v_2 - v_0\)为3D三角形(非uv空间中)的两个边向量,它们所对应的切线空间中的边向量分别为\((\Delta_0 , \Delta v_0) = (u_1 - u_0, v_1 - v_0),(\Delta_1 , \Delta v_1) = (u_2 - u_0, v_2 - v_0)\)

    用到局部空间的坐标变化来表示:\(\large \left[\begin {array} {cc} e_{0,x} & e_{0,y} & e_{0,z} \\ \large e_{1,x} & e_{1,y} & e_{1,z} \end {array} \right] = \left[\begin {array} {cc} \Delta_{u_0} & \Delta_{v_0} \\ \large \Delta_{u_1} & \Delta_{v_1} \end {array} \right] \left[\begin {array} {cc} T_{x} & T_{y} & T_{z} \\ \large B_{x} & B_{y} & B_{z} \end {array} \right]\).因为我们已经知道局部空间和uv的坐标,那TBN矩阵肯定是可以求得,需要对uv矩阵求逆:
    \(\large \left[\begin {array} {cc} T_{x} & T_{y} & T_{z} \\ \large B_{x} & B_{y} & B_{z} \end {array} \right] = \large \left[\begin {array} {cc} \Delta_{u_0} & \Delta_{v_0} \\ \large \Delta_{u_1} & \Delta_{v_1} \end {array} \right]^{-1} \large \left[\begin {array} {cc} e_{0,x} & e_{0,y} & e_{0,z} \\ \large e_{1,x} & e_{1,y} & e_{1,z} \end {array} \right] \large = \frac{1}{\Delta_{u_0} \Delta{v_1} - \Delta{v_0}\Delta{u_1}} \left[\begin {array} {cc} \Delta_{v_1} & -\Delta_{v_0} \\ \large -\Delta{u_1} & \Delta_{u_0} \end {array} \right] \left[\begin {array} {cc} e_{0,x} & e_{0,y} & e_{0,z} \\ \large e_{1,x} & e_{1,y} & e_{1,z} \end {array} \right]\)

    上述过程使用了逆矩阵的性质:矩阵\(A = \left[\begin {array} {cc} a & b \\ \large c & d \end {array} \right]\),有\(\large A^{-1} = \frac{1}{ad - bc} \left[\begin {array} {cc} d & -b \\ \large -c & a \end {array} \right]\)

  • 顶点切线空间

    • 定义:在每个顶点处指定切向量,并对顶点求平均(共用该顶点的所有三角形的切向量的平均值)并进行归一化,使这三个向量依然两两正交且含有单位长度(可使用Gram-Schmidt).令顶点法线趋于平滑的表面
    • 为什么需要它:虽然我们已经知道任意三角形所对应的切线空间,但该切线空间存在一定缺点——在法线贴图时使用该切线空间,会导致渲染结果呈现明显的三角形划分痕迹。这是因为切线空间必定位于对应三角形的所在平面,这会导致图面不够平滑,也就是说三角形和三角形的共用边
  • 世界空间和切线空间的变换

    • 局部空间到切线空间

      在确定网格三角形每个顶点处的正交归一化TBN基,且已求出切线空间到局部空间的变换矩阵:\(\large M_{object} = \left[\begin {array} {cc} T_x & T_y & T_z \\\large B_x & B_y & B_z \\ \large N_x & N_y & N_z \end {array} \right]\),由于该矩阵为正交矩阵,
      因此它的逆矩阵就是它的转置\(\left[\begin {array} {cc} T_x & B_x & N_x \\ \large T_y & B_y & N_y \\ \large T_z & B_z & N_z \end {array} \right]\)

    • 切线空间到世界空间

      一般而言需要将法向量变换至世界空间下进行光照计算:\(n_{world} = (n_{tangent} M_{object})M_{world} = n_{tangent}(M_{object} M_{world})\),尤其已经知道局部空间下的法向量坐标,因此再乘上一个世界矩阵即可

  • 注意点:一般切向量T和B在局部空间中不是单位长度,且若纹理发生扭曲形变,这两个向量也不是正交的归一化向量

实现

法线贴图的流程:

  1. 美术制作需要的法线图,将其存于图像文件中
  2. 在app初始化时加载这些图像,并用其创建2d纹理
  3. 对于每个三角形而言,计算它的切向量T(求平均值)。但若是规则的三角形网格,则可以直接指定切向量,而无需求平均
  4. 在VS中,将顶点法线和切向量变换至世界空间,并将结果输出至PS
  5. 通过插值切向量和法向量来构建三角形表面每个像素点处的TBN基,再将该TBN基从切线空间变换至世界空间

实现:

  • 将法线图变换至世界空间

    float3 NormalSampleToWorldSpace(float3 normalMapSample, float3 unitNormalW, float3 tangentW)
    {
    	//解压坐标分量,从[0,1]到[-1,1]
    	float3 normalT = 2.0f*normalMapSample - 1.0f;
    
    	// 构建正交规范基。因为插值后TN向量可能为非正交规范向量
    	float3 N = unitNormalW;
    	float3 T = normalize(tangentW - dot(tangentW, N)*N);
    	float3 B = cross(N, T);
    
    	float3x3 TBN = float3x3(T, B, N);
    	float3 bumpedNormalW = mul(normalT, TBN);
    
    	return bumpedNormalW;
    }
    

    构建正交规范基
    image-20230323234141686

  • 整体实现

    struct VertexIn
    {
    	//...
    	float3 TangentU : TANGENT;
    };
    
    struct VertexOut
    {
    	//...
        float3 NormalW : NORMAL;
    };
    
    VertexOut VS(VertexIn vin)
    {
        //...
        // 若此处不是等比缩放,需要使用世界矩阵的逆转置矩阵
        vout.NormalW = mul(vin.NormalL, (float3x3)gWorld);
    	vout.TangentW = mul(vin.TangentU, (float3x3)gWorld);
    	//...
    }
    
    float4 PS(VertexOut pin) : SV_Target
    {
        //...
    	// 插值后法线可能非归一化向量
        pin.NormalW = normalize(pin.NormalW);
    	
    	float4 normalMapSample = gTextureMaps[normalMapIndex].Sample(gsamAnisotropicWrap, pin.TexC);
    	float3 bumpedNormalW = NormalSampleToWorldSpace(normalMapSample.rgb, pin.NormalW, pin.TangentW);
    
        //...
        const float shininess = (1.0f - roughness) * normalMapSample.a;
        Material mat = { diffuseAlbedo, fresnelR0, shininess };
        float3 shadowFactor = 1.0f;
        float4 directLight = ComputeLighting(gLights, mat, pin.PosW,
            bumpedNormalW, toEyeW, shadowFactor);
    
        float4 litColor = ambient + directLight;
    
    	// Add in specular reflections.
    	float3 r = reflect(-toEyeW, bumpedNormalW);
    	float4 reflectionColor = gCubeMap.Sample(gsamLinearWrap, r);
    	float3 fresnelFactor = SchlickFresnel(fresnelR0, bumpedNormalW, r);
        litColor.rgb += shininess * fresnelFactor * reflectionColor.rgb;
    	
        // Common convention to take alpha from diffuse albedo.
        litColor.a = diffuseAlbedo.a;
    
        return litColor;
    }
    

reference

Directx12 3D 游戏开发实战