Bootstrap

UE4_材质基础_切线空间与法线贴图

学习笔记,不喜勿喷,侵权立删,祝愿大家生活越来越好!                 

一、切线空间

在《OpenGL基础11:空间》中提到了观察空间、裁剪空间、世界空间等。切线空间和它们一样,都属于坐标空间

上面就是一个切线空间的例子,对于切线空间:

  • N:该顶点本身的法线方向,z轴
  • T:该顶点的一条切线,但由于切线数量有无数条,其一般由模型给定,对应着UV图中的U,也就是使用和纹理坐标方向相同的那条
  • B:由前两者叉乘得到,对应着UV图中的V

UV图:用于告知计算机,如何用2维的贴图包住3维的物体,本质上UV图提供了一种模型表面与纹理图像之间的连接关系,也就是确定纹理图像上的每一个像素应该放置在模型表面的哪一个顶点上,如果没有UV图,多边形网格将不能被渲染出纹理,其中U和V分别指的是纹理空间的水平轴和垂直轴

二、为什么需要切线空间


在此之前,先需要大致了解一下法线贴图(Normal Mapping):为了得到正确的光照,需要知道物体每个顶点的法向量,但为了保证效率,一般物体的顶点不会太多,就像一块砖块,它的表面往往凹凸不平,但事实上它可能单纯的只是一个立方体,每一面给上了一个贴图。这样如果还想要体现出物体“凹凸不平”的效果,就需要用到法线贴图或者高度贴图,也就是对于纹理的每一个像素,都指定一个特定的法向量!

很巧,法向量是个3维向量,而颜色正好也是一个3维向量,所以直接将向量信息存储成颜色信息没有任何的压力

可是这样就出现了另一个问题:就像下图是一个黑色小包模型的一部分,中间有两个拉链拉头,这两个子模型是完全一样的,唯一的区别就是位置不同从而光照的效果不同。那么很明显,为了节省空间和性能,用的也会是同样的贴图,但是!他们的法向量却不一样,也就是说这两个完全相同的物体并不能使用同一张法线贴图(法线贴图可以说只是个数据存储媒介,和颜色没有关系)

确实可以为这些相同的子模型准备不同的法线贴图,就像一个六个面相同的立方体,为它专门准备6张法线贴图,但是有可能这样的子模型特别多,并且方向都不相同,这个时候还准备不同的法线贴图就比较尴尬了

上面的问题归根结底就是物体的朝向问题,我们只要找到这样的一个空间:无论当前是什么朝向,所有“纹理像素点”的法向量一定都是不变的,并且可以通过空间变换就可以得到世界坐标下正确的法向量,就可以解决问题了。所有相同的面,都可以赋予同样的法线贴图。

这个空间就是切线空间,酷不酷?

三、如何求出切线空间

切线空间是什么?对于一个网格模型,我们逐顶点来分析,每个顶点都有着自己的切线空间,如下图所示,我们可以将其称为TBN空间。其中N代表该点处的法线,T(tangent)和B(binormal)都是该点处的切线。由于一个点处的切线有无数条,我们指定T切线是沿着纹理的u坐标方向的,B切线是沿着纹理的v坐标方向的。 

从最简单的开始算:一个4个顶点100%平坦的平面,怎么求出它的切线空间呢?

T (Tangent) B (Bitangent) N (Normal) 向量一个一个来:

N 就不说了,它就是法向量,然后就是 T 切线向量:

肯定的,T、B、N都是单位长度,这样的话根据右图可以得到:

\begin{array}{l} E_{1}=\Delta U_{1} T+\Delta V_{1} B \\ E_{2}=\Delta U_{2} T+\Delta V_{2} B \end{array}

只要理解了这个公式后面就都好办了,首先可以看出,这是求的顶点P2的切线空间,而P1、P2、P3是当前P2所在的一个三角形片元,其次,E1、E2其实已经当前点的其中的两条切线了,只不过为了统一切线,切线的方向必须是纹理UV的方向,正好点 P1和P3的纹理坐标映射在主副切线方向上的向量之和正是向量E1和向量E2之和,同时也是说E2是三角形的一条边,这个三角形的另外两条边是\DeltaU2和\DeltaV2 ,它们与切线向量T和副切线向量B方向相同。

既然 EUV 都是已知的,那么就可以求出 T 和 B 了:

上面的公式可以写成:

\begin{array}{l} \left(E_{1 x}, E_{1 y}, E_{1 z}\right)=\Delta U_{1}\left(T_{x}, T_{y}, T_{z}\right)+\Delta V_{1}\left(B_{x}, B_{y}, B_{z}\right) \\ \left(E_{2 x}, E_{2 y}, E_{2 z}\right)=\Delta U_{2}\left(T_{x}, T_{y}, T_{z}\right)+\Delta V_{2}\left(B_{x}, B_{y}, B_{z}\right) \end{array}

转成矩阵就是:

\left[\begin{array}{ccc} E_{1 x} & E_{1 y} & E_{1 z} \\ E_{2 x} & E_{2 y} & E_{2 z} \end{array}\right]=\left[\begin{array}{cc} \Delta U_{1} & \Delta V_{1} \\ \Delta U_{2} & \Delta V_{2} \end{array}\right]\left[\begin{array}{ccc} T_{x} & T_{y} & T_{z} \\ B_{x} & B_{y} & B_{z} \end{array}\right]

两边都乘以\DeltaU和\DeltaV的逆矩阵:

好了搞定,把未知数成功扔到了左边

转成代码就是(代码中的数据输入是最简单情况:一个平面正方形,4个顶点分别是:(-1, -1, 0)、(-1, 1, 0)、(1, -1, 0)和(1, 1, 0),对应的纹理顶点为(0, 0)、(0, 1)、(1, 0)和(1, 1)):

void GetTangent()
{
    glm::vec3 pos1(-1.0, 1.0, 0.0);
    glm::vec3 pos2(-1.0, -1.0, 0.0);
    glm::vec3 pos3(1.0, -1.0, 0.0);
    glm::vec3 pos4(1.0, 1.0, 0.0);
    glm::vec2 uv1(0.0, 1.0);
    glm::vec2 uv2(0.0, 0.0);
    glm::vec2 uv3(1.0, 0.0);
    glm::vec2 uv4(1.0, 1.0);
    glm::vec3 normal(0.0, 0.0, 1.0);
 
    glm::vec3 tangent1, bitangent1;     //第一个三角形(1,2,3)的顶点切线空间
    glm::vec3 tangent2, bitangent2;     //第二个三角形(1,3,4)的顶点切线空间
 
    glm::vec3 edge1 = pos2 - pos1;
    glm::vec3 edge2 = pos3 - pos1;
    glm::vec2 deltaUV1 = uv2 - uv1;
    glm::vec2 deltaUV2 = uv3 - uv1;
    GLfloat f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
    tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
    tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
    tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
    tangent1 = glm::normalize(tangent1);
    bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
    bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
    bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
    bitangent1 = glm::normalize(bitangent1);
 
    edge1 = pos3 - pos1;
    edge2 = pos4 - pos1;
    deltaUV1 = uv3 - uv1;
    deltaUV2 = uv4 - uv1;
    f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
    tangent2.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
    tangent2.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
    tangent2.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
    tangent2 = glm::normalize(tangent2);
    bitangent2.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
    bitangent2.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
    bitangent2.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
    bitangent2 = glm::normalize(bitangent2);
 
    GLfloat quadVertices[] =
    {
        //位置                  //法向量          //纹理坐标    //切线                              //副切线
        pos1.x, pos1.y, pos1.z, nm.x, nm.y, nm.z, uv1.x, uv1.y, tangent1.x, tangent1.y, tangent1.z, bitangent1.x, bitangent1.y, bitangent1.z,
        pos2.x, pos2.y, pos2.z, nm.x, nm.y, nm.z, uv2.x, uv2.y, tangent1.x, tangent1.y, tangent1.z, bitangent1.x, bitangent1.y, bitangent1.z,
        pos3.x, pos3.y, pos3.z, nm.x, nm.y, nm.z, uv3.x, uv3.y, tangent1.x, tangent1.y, tangent1.z, bitangent1.x, bitangent1.y, bitangent1.z,
        pos1.x, pos1.y, pos1.z, nm.x, nm.y, nm.z, uv1.x, uv1.y, tangent2.x, tangent2.y, tangent2.z, bitangent2.x, bitangent2.y, bitangent2.z,
        pos3.x, pos3.y, pos3.z, nm.x, nm.y, nm.z, uv3.x, uv3.y, tangent2.x, tangent2.y, tangent2.z, bitangent2.x, bitangent2.y, bitangent2.z,
        pos4.x, pos4.y, pos4.z, nm.x, nm.y, nm.z, uv4.x, uv4.y, tangent2.x, tangent2.y, tangent2.z, bitangent2.x, bitangent2.y, bitangent2.z
    };
    //……
}

四、TBN与空间变换

其实关于切线空间的了解到这里就结束了,下面可以当作扩展记录一下:

仔细看上面转换过程中的一个式子:

换一种表示方法就是:

或许就可以更容易发现, T 和 B 其实正是一组基向量,抛开法向量那一维,T 和 B 正好可以将二维uv空间中的向量和点,转到三维世界空间的某个平面上,而这个平面正好是切线平面

也就是说,如果T和B是已知的、切线空间是已知的,那么我们很容易就可以将二维纹理坐标映射到三维空间顶点坐标,而上面计算的所以意义,就在于我们知道二维空间纹理坐标,也知道三维空间顶点坐标,要反过来去找这个空间

如果理解了这个,就好办了,不用纠结于坐标和法线,它的本质就是如此

五、法线贴图存储的是什么?

法线贴图其实并不是真正的贴图,所以也不会直接贴到物体的表面,它所起的作用就是记录每个点上的法线的方向。所以这个贴图如果看起来也会比较诡异,经常呈现一种偏蓝紫色的样子,主要是因为法线纹理的RGB通道存储了在每个顶点各自的切线空间中的法线方向的映射值,其实法线向量有三个值,这三个值可以分别对应RGB三个值,一般法线垂直于物体的分量多一些,也就是Blue多一些,也就偏向于蓝色了。

六、法线压缩

 压缩的第一步很简单,由于归一化的法线长度为1,且在切线空间下,法线的z分量不可能为负数,所以只需要存储x和y值即可。本文的压缩方法在这一步压缩的基础上,利用现有的纹理压缩方法,进行进一步压缩。

  在支持DirectX10的显卡上,可以使用BC5格式进行压缩。BC5的压缩方法内存情况如图1所示,该格式有两个颜色通道(R和G),每个通道使用两个1Byte的值来表示,每个像素使用3Bit在这两个颜色值之间进行插值。将法线贴图中每个法线的x和y值利用BC5格式进行压缩,如图2所示。对每个Block(16个像素)存储x的最大、小值和y的最大、小值,然后每个像素利用3Bit进行插值,相当于在图2右图所示的8*8区域内取样(为了简化表示,图2只画了4*4点)。

使用一般纹理mipmap方法生成的法线贴图对于漫反射表面基本没问题,但是在镜面表面会导致严重的视觉问题。对于漫反射表面来说,光照的计算公式为l·nl为光线方向的相反方向,n为法线,l·n1 l·n2 l·n3 l·n4 = l·(n1 n2 n3 n4) / 4,而mipmap则是事先计算(n1 n2 n3 n4) / 4,所以对于漫反射表面,对法线贴图使用传统方式的mipmap基本没问题。为什么是基本没问题而不是完全没问题呢?因为这里存在一个近似,若l·< 0,则光照值为0(光照不能为负),若将这个因素考虑进去,漫反射表面也会有问题,不过在实际当中这种情况表现不明显,所以可以认为基本没问题。

  对于镜面表面来说,当视线偏离反射光线方向的时候,光照强度会急剧下降,反映在公式中是因为其含有cosm(h·n)项(具体公式可以Google),而漫反射光照是线性变化,所以对于镜面表面,不能使用传统方法生成法线贴图的mipmap。法线贴图对于镜面反射的mipmap如图3所示,第一幅图中有4个像素,每个像素有法线和镜面反射波瓣(红色的是法线,周围一圈是镜面反射波瓣,镜面反射波瓣用于表示不同方向的反射强度)。图2中间部分,表示正确的mipmap情况,分别从4个像素合并为2个像素,从两个像素合并为1个像素。而现有的方法中,没有方法可以做到这样的mipmap,所以只能用其他方法进行近似。

图2的底部左图,是使用一般纹理的mipmap方法对法线进行平均,可以看到这种方法产生出的镜面反射波瓣和正确的镜面反射波瓣差距很大,其根本原因是使用线性方法对非线性的参数进行计算。图2底部图右图,每次在平均法线的同时,改变表面的光泽度(即改变镜面光公式中的m),虽然最终结果与正确的mipmap有一些差距,但是比一般纹理的mipmap的方法要好很多。

  所以,对法线贴图的mipmap方法之一,就是在使用一般纹理的mipmap方法对法线进行平均的同时,每张mipmap都必须附带一张光泽贴图(gloss map),记录每个像素点的光泽度(即m),m的计算原则就是让最后的镜面反射波瓣与正确的镜面反射波瓣最接近。当然,还有其他很多方法能得到不错的结果,具体可以参看《Real-Time Rendering 3rd》,或去搜索相关论文。

七、法线混合算法

在平时的工作中,我们经常需要在一张基础的法线贴图上融合一张细节纹理贴图,也即将两张法线贴图融合。这一节我们就来简单讲一讲有哪些常用的法线融合算法。

Linear Blending(线性混合)

简单的线性混合代码如下:

float3 n1 = tex2D(texBase,   uv).xyz*2 - 1;
float3 n2 = tex2D(texDetail, uv).xyz*2 - 1;
float3 r  = normalize(n1 + n2);
return r*0.5 + 0.5;

可以看出,我们只是简单地Unpack Normal,然后相加后归一化,再pack回去。这种做法类似于做平均化处理,由于两张纹理不尽相同,我们最后得到的结果实际上是对两张纹理"Flattening(平铺化)"的效果。如果其中一张是平面纹理的话(比如(0,0,1)),我们最后得到的结果其实就是另一张纹理图展平后的结果,这可能并不是我们想要的(我们想让另一张不起作用)。

Overlay Blending(叠加混合)

叠加混合代码如下:

float3 n1 = tex2D(texBase,   uv).xyz;
float3 n2 = tex2D(texDetail, uv).xyz;
float3 r  = n1 < 0.5 ? 2*n1*n2 : 1 - 2*(1 - n1)*(1 - n2);
r = normalize(r*2 - 1);
return r*0.5 + 0.5;

叠加混合算法其实就是PS中的叠加混合,这种算法看似合理,实际上仍然显示不正确,我们仍然是对贴图的通道做统一处理,并没有根据矢量的特性对通道进行分别处理。之所以会有人使用叠加混合,可能是因为这种混合方式相比PS其他混合显示效果更好一些吧。

Partial Derivative Blending(偏导混合)

偏导混合的代码如下:

float3 n1 = tex2D(texBase,   uv).xyz*2 - 1;
float3 n2 = tex2D(texDetail, uv).xyz*2 - 1;
float2 pd = n1.xy/n1.z + n2.xy/n2.z; // Add the PDs
float3 r  = normalize(float3(pd, 1));
return r*0.5 + 0.5;

为了代码的健壮性,第三四行代码应改成:

float3 r = normalize(float3(n1.xy*n2.z + n2.xy*n1.z, n1.z*n2.z));

偏导函数理论的细节这里不赘述,从图中我们可以看出,偏导混合的结果相比之前有了很大提升(原始贴图的细节被保留下来),但问题依然存在,仔细观察可以发现圆锥部分的细节贴图仍然被Flatten,但这种融合在处理不同材质间过渡时是非常不错的

处理材质过渡时可能用到的代码:

float2 pd = lerp(n1.xy/n1.z, n2.xy/n2.z, blend);
float3 r = normalize(float3(pd, 1));

WhiteOut Blending

代码如下:

float3 n1 = tex2D(texBase,   uv).xyz*2 - 1;
float3 n2 = tex2D(texDetail, uv).xyz*2 - 1;
float3 r  = normalize(float3(n1.xy + n2.xy, n1.z*n2.z));
return r*0.5 + 0.5;

这种混合方法第一次在SIGGRAPH 2007 上被提出,从代码中可以看出,这种方式类似于偏导混合,只是在xy通道上没有乘以Z分量。从混合结果来看,这种方法很好地解决了圆锥面细节贴图flatten的问题,但仍然存在flatten的问题。

UDN Blending

代码如下:

float3 n1 = tex2D(texBase,   uv).xyz*2 - 1;
float3 n2 = tex2D(texDetail, uv).xyz*2 - 1;
float3 r  = normalize(float3(n1.xy + n2.xy, n1.z));
return r*0.5 + 0.5;

这种更简单的混合方法在虚幻引擎开发者论坛上被提出,相比WhiteOut Blending,这里的变化只是在Z分量上取消乘以n2.z。从另一个角度看,这种混合方式只是在线性混合的基础上取消了z分量上的相加。对比WhiteOut Blending,这种混合方式在边界的混合效果会差一些,flatten的效果更明显,但由于这种混合方式更加节省着色器指令,在低端机的使用上会更频繁。

对于法线纹理混合,我们通常需要满足以下三点:
逻辑性:操作过程可以使用简单数学几何实现
特殊情况:当其中一个纹理是平面时,结果显示为另一纹理
避免Flattening:两张法线纹理的强度都得到保存
上面的几种方法对以上几点很难兼顾,下面的方法将通过旋转(或者说重定向)细节贴图的方法来使细节纹理匹配基础纹理。这个过程就像是对几何物体进行光照计算时对切线空间法线进行变换一样。
举个简单例子,我们有一个物体平面的法线为s,基础纹理的法线方向为 t,细节纹理的法线方向为u,我们可以通过计算s到t的变换矩阵或者说算子,然后将算子应用到u上,我们就得到了最终的结果r,如图:

Unity

代码如下:

float3 n1 = tex2D(texBase,   uv).xyz*2 - 1;
float3 n2 = tex2D(texDetail, uv).xyz*2 - 1;

float3x3 nBasis = float3x3(
     float3(n1.z, n1.y, -n1.x), // +90 degree rotation around y axis
     float3(n1.x, n1.z, -n1.y), // -90 degree rotation around x axis
     float3(n1.x, n1.y,  n1.z));

float3 r = normalize(n2.x*nBasis[0] + n2.y*nBasis[1] + n2.z*nBasis[2]);
return r*0.5 + 0.5;

Unity采用的方法是根据基础法线纹理的x,y轴进行旋转得到重定向后的矩阵,再应用该矩阵对细节法线贴图进行变换得到混合后的结果。但是,这种方法当且仅当n1为(0,0,±1)时表现正确,当n1偏离这个值时,结果会逐渐偏离正确的显示效果。如上图9显示。

我们可以看出,当n1与x轴重合时,这些点会塌缩成一个圈,因为这个时候,细节贴图的变换矩阵为:

Reoriented Normal Mapping(RNM Blending)

代码如下:

float3 t = tex2D(texBase,   uv).xyz*float3( 2,  2, 2) + float3(-1, -1,  0);
float3 u = tex2D(texDetail, uv).xyz*float3(-2, -2, 2) + float3( 1,  1, -1);
float3 r = t*dot(t, u)/t.z - u;
return r*0.5 + 0.5;

相比Unity的做法,这种方法也是使用一个基于n1变换的矩阵来对n2进行变换,只不过变换过程采用四元数代替,修正了图8中unity出现的错误。这种方法的另一个好处是,如果t是单位长度,u的长度是不变的,也就是说如果u也是单位长度,这种方法是不需要进行(normalize)标准化处理的(会节省不少着色器指令消耗)。但在实际操作中这种理论情况很难实现,很多因素诸如:量化、压缩纹理、mipmaping、滤波操作等都可能有所影响。你可能在漫反射效果中无法看出区别,但这仍然会切切实实地影响PBR理论遵循的能量守恒定律。保守起见,我们最好还是对结果进行归一化处理。

float3 r = normalize(t*dot(t, u) - u*t.z);
这种方法是默认法线z值是>=0的,但是这在运算过程中并不总是满足的,这种方法的潜在问题是如果法线在程序编写中已经进行了重定向,然后被压缩进一张双分量格式的法线贴图,虽然解压重建贴图的过程通常假设Z>=0。这种情况下最直接的解决方法就是对z值clamp一下,然后再归一化,这样之后再压缩进贴图。
UE4里的BlendAngleCorrectNormal节点,是RNMBlending。

八、UE4法线调整

1、调整法线强度

想控制一张法线贴图的呈现强度,不能直接对整个贴图的UV进行运算,需要对法线贴图的RG通道进行运算,并将每个通道的计算结果通过追加节点Append组成新的数值,改变强度参数,从而改变物体表现的强度。

1.各通道使用乘法与强度参数NormalIntensify相乘

2.使用追加节点,组合新的向量。并将值与Normal链接。

经过法线增强后:

2、法线贴图融合 BlendAngleCorrectedNormals

使用 BlendAngleCorrectedNormals 节点,完成两张法线贴图的叠加。

3、使用普通贴图制作法线 NormalFromHeightMap

这是个材质函数,可以查看节点组成。

需要将贴图转换为纹理对象

参考教程:https://blog.csdn.net/Jaihk662/article/details/107917594                      
参考教程:https://blog.csdn.net/ZJU_fish1996/article/details/83934059

参考教程:OpenGL基础46:切线空间-CSDN博客

参考教程:Learn OpenGL, extensive tutorial resource for learning Modern OpenGL

参考教程:https://zhuanlan.zhihu.com/p/364821684

参考教程:https://www.cnblogs.com/wangchengfeng/p/3475489.html

参考教程:

UE4-材质法线强度调整、法线贴图混合、自定义材质函数、材质边缘过渡、植被动态效果_ue4法线强度调节节点-CSDN博客

 

;