OpenGL基础47:法线贴图

前置:OpenGL基础46:切线空间

这章在《OpenGL基础46:切线空间》之后,如果不了解切线空间的话,是没法很好理解法线贴图的

五、逐像素光照

前文提到过:为了得到正确的光照,需要知道物体每个顶点的法向量,但为了保证效率,一般物体的顶点不会太多,就像一面很长很宽的墙壁,它的表面往往凹凸不平,但事实上它可能单纯的只是一个立方体,每一面给上了一个贴图。这样如果还想要体现出物体“凹凸不平”的效果,就需要用到法线贴图或者高度贴图

之前所有的光照都是逐顶点光照:也就是说对于当前片段,它的法向量等于当前片段所在片元顶点的法向量,也因此,如果一个模型的顶点数量不足,那么三个顶点之间的三角形片元就会是个“完全平滑”的表面,并且这样的表面越“大”,就越不真实,因为它们的法向量一致(除非你渲染的是一块完美无瑕的玻璃表面),如下效果:

这就是逐顶点光照的一个例子,它没有应用法线贴图,因此对于墙壁面向我们的这一个面,它的每一个片段的法向量都是(0, 0, 1),也就是完美垂直于墙壁,这样看上去效果好像没什么问题,确实墙壁的法向量和效果都是没问题的,问题在于这个墙壁它不应该那么“平”!通过对比下面这个带着法线贴图的效果就可以看出区别了:

这就是逐像素光照的一个例子,它应用了法线贴图,每一个片段都指定了一个特定的法向量,尽管它们的方向大致上都近似于(0, 0, 1),但实际上各有细微的差别,还记不记得下面这张图,右边才是真实的墙壁表面,它的细节应该是凹凸不平的,也就是逐像素光照所能体现的

只要你的模型顶点数量足够的多(模型足够精细),多到基本上每个三角形面都“小”到甚至体现不到一个像素点上,那么就可以起到和上面不用法线贴图一样的效果,但是这样可能会把你卡死

六、法线贴图

法线贴图原理非常简单,就一句话:贴图中的每一个像素颜色刚好是个vec3类型(RGB),而法向量正好也是个vec3类型(xyz),因此可以将法向量的信息直接作为“颜色”存储在一张贴图里

当然,法线贴图中法向量的信息是在切线空间中的,因此在计算光照的时候,需要将光的方向和位置转入切线空间(为什么需要切线空间:在《OpenGL基础46:切线空间》中讲的还是很清楚的)

法线贴图有一个非常大的特点:它的主色调都是较深的蓝紫色,就像下面这样:

对于法线贴图中的每一个像素点,它的颜色都靠近于(0.5, 0.5, 1),这也是整张图偏蓝紫的原因,之所以是(0.5, 0.5, 1)这个颜色值,主要取决于以下因素:

  1. 对于切线空间TBN,法向量是第三维,这样如果当前像素的法向量刚好就是当前片元的顶点法向量,那么这个法向量在TBN空间中的值正好就是(0, 0, 1),也就是正法线
  2. 在TBN空间中的法向量坐标是可能为负的,它的范围是[-1, 1],但是颜色的RGB范围确是[0, 1],因此需要进行映射,这样的话,原本的法向量(0, 0, 1)对应到颜色正是(0.5, 0.5, 1),也就是上面的蓝紫色

综合来讲:法线贴图其实就是存储了在每个顶点各自的切线空间中,法线的扰动方向。所谓的扰动方向,其实就是每个法向量实际的方向,因为如果一个表面完全是平的,那么每个片段的法向量就必然是(0, 0, 1),但若要从光照中体现出凹凸感,那么法向量就会于正法线(0, 0, 1)有一定的偏移,偏移角越大当前片段就越“陡”,凹凸感可能就越明显,所以你最后得到的每个像素的法向量必然是一个接近于(0, 0, 1)的向量映射到颜色空间中的结果

七、应用例子

回到代码,切线空间的计算在上一章《OpenGL基础46:切线空间》已经有了,但是一个好消息是,如果是加载的模型,那么内置的模型库,就像assimp库都会帮你直接把切向量和副切线计算好,也就是说前面的代码直接就省掉了,只需要读取就可以:

const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace);

Assimp在加载模型的时候可以调用aiProcess_CalcTangentSpace,当aiProcess_CalcTangentSpace应用到Assimp的ReadFile函数时,Assimp会为每个加载的顶点计算出柔和的切线和副切线向量

这样只需要在处理顶点坐标、法线坐标和纹理坐标时多处理个切线和副切线、传递给着色器就OK了

//处理顶点坐标、法线和纹理坐标
for (GLuint i = 0; i < mesh->mNumVertices; i++)
{
    Vertex vertex;
    glm::vec3 vector;
    vector.x = mesh->mVertices[i].x;
    vector.y = mesh->mVertices[i].y;
    vector.z = mesh->mVertices[i].z;
    vertex.Position = vector;
    vector.x = mesh->mNormals[i].x;
    vector.y = mesh->mNormals[i].y;
    vector.z = mesh->mNormals[i].z;
    vertex.Normal = vector;
    if (mesh->mTangents != nullptr && mesh->mBitangents != nullptr)
    {
        vector.x = mesh->mTangents[i].x;
        vector.y = mesh->mTangents[i].y;
        vector.z = mesh->mTangents[i].z;
        vertex.Tangent = vector;
        vector.x = mesh->mBitangents[i].x;
        vector.y = mesh->mBitangents[i].y;
        vector.z = mesh->mBitangents[i].z;
        vertex.Bitangent = vector;
    }
    if (mesh->mTextureCoords[0])            //不一定有纹理坐标
    {
        glm::vec2 vec;
        //暂时只考虑第一组纹理坐标,Assimp允许一个模型的每个顶点有8个不同的纹理坐标,只是可能用不到
        vec.x = mesh->mTextureCoords[0][i].x;
        vec.y = mesh->mTextureCoords[0][i].y;
        vertex.TexCoords = vec;
    }
    else
        vertex.TexCoords = glm::vec2(0.0f, 0.0f);
    vertices.push_back(vertex);
}

之后就是着色器部分,主代码不需要有任何改动:

在顶点着色器中,需要乘以model矩阵以创建实际的TBN矩阵,当然最好是使用法线矩阵,这样就只需要关心向量的方向,不会被物体缩放操作所影响,当然了,有了T和N,B可以直接通过叉乘算出来,下面是施密特正交化算法的一个例子

当在更大的网格上计算切线向量的时候,它们往往有很大数量的共享顶点,当法向贴图应用到这些表面时将切线向量平均化通常能获得更好更平滑的结果,但这样做有个问题:就是TBN向量可能不会互相垂直,这意味着TBN矩阵不再是正交矩阵了,法线贴图可能会稍稍偏移,这个时候若对TBN向量进行重正交化,就可以使每个向量重新垂直

#version 420 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texture;
layout (location = 3) in vec3 tangent;
layout (location = 4) in vec3 bitangent;
layout (location = 5) in mat4 model;
out VS_OUT
{
    vec2 texIn;
    vec3 normalIn;
    vec3 fragPosIn;
    mat3 TBN;
}vs_out;
layout (std140, binding = 0) uniform Matrices
{
    mat4 view;                  //观察矩阵
    mat4 projection;            //投影矩阵
};
void main()
{
    gl_Position = projection * view * model * vec4(position, 1.0);
    vs_out.fragPosIn = vec3(model * vec4(position, 1.0f));
    vs_out.texIn = texture;

    mat3 normalMat = transpose(inverse(mat3(model)));
    vs_out.normalIn = normalMat * normal;
    vec3 T = normalize(normalMat * tangent);
    vec3 N = normalize(normalMat * normal);
    vs_out.normalIn = normalMat * normal;
    T = normalize(T - dot(T, N) * N);
    vec3 B = cross(T, N);

    vs_out.TBN = transpose(mat3(T, B, N));
}

接下来就是片段着色器,有两种选择:

  1. 直接通过TBN矩阵把切线坐标空间的向量转换到世界坐标空间,这样只需要在片段着色器中将法线左乘TBN矩阵就好了
  2. 求出TBN矩阵的逆矩阵,然后把世界坐标空间的向量转换到切线坐标空间中计算(需要转换的有光照的位置和方向)。因为不需要对法线进行计算,可以直接在顶点着色器中算好传入片段着色器

对于①代码如下:

vec3 normal = normalize(normalIn);                    //法向量
vec3 fragPos = fragPosIn;                             //当前片段坐标
vec3 viewDir = normalize(viewPos - fragPos);          //观察方向
if (CheckNormalTex())           //如果存在法线贴图
{
    normal = texture(material.texture_normal1, texIn).rgb;
    normal = normalize(normal * 2.0 - vec3(1.0));
    normal = normalize(TBN * normal);
}

对于②代码如下:

//……
vec3 normal;            //法向量
vec3 fragPos;           //当前片段坐标
vec3 viewDir;           //观察方向
if (CheckNormalTex())           //如果存在法线贴图
{
    sun = sunLight;
    sun.direction = TBN * sun.direction;
    for (int i = 0; i <= 2; i++)
    {
        point[i] = pointLights[i];          //光照位置
        point[i].position = TBN * point[i].position;
    }
    spot[0] = spotLights[0];
    spot[0].position = TBN * spot[0].position;
    spot[0].direction = TBN * spot[0].direction;
    normal = texture(material.texture_normal1, texIn).rgb;
    normal = normalize(normal * 2.0 - vec3(1.0));
    fragPos = TBN * fragPosIn;
    viewDir = normalize(TBN * viewPos - fragPos);
}
else
{
    sun = sunLight;
    for (int i = 0; i <= 2; i++)
        point[i] = pointLights[i];
    spot[0] = spotLights[0];
    normal = normalize(normalIn);
    fragPos = fragPosIn;
    viewDir = normalize(viewPos - fragPos);
}
//

如果没问题的话,效果就能很容易看得出来

法线贴图还可以用来做UV动画,例如表现出岩浆的流动等等,这个后面有机会再说了

八、美术部分的坑

①assimp不支持.max后缀的模型文件

需要下载个3dmax,将.max文件转化成.obj或者.fbx文件,也可以让你的美术朋友帮你转一下

②你的法线贴图不正确,应用之后得到了明显错误的光照效果

是的,出现上左图的情况不一定是你的TBN计算错误,而可能是美术资源应用的不对!

先检查以下你的法线贴图,它的亮度是否过高了:

如上两张法线贴图,如果你应用的是右边那张贴图,那么恭喜你用错了!

这是一个巨坑,不少模型文件中自带的法线贴图就会是右边这种,但这种并不是不正确,而是图片有一定的透明度,在应用于法线贴图时必须先将Alpha层去掉!

这里不讲专业的美术知识,只举一个例子:对于一张32位的图,它有RGBA四个颜色属性,对于一个点,它的颜色值为(0.5, 0.5, 1, 0.2),很明显我们是只需要它的前3个属性,也就是RGB属性(0.5, 0.5, 1),但是事与愿违,如果这张图保存于错误的格式,又或者被读取时没有考虑到alpha,那么你最终读取到的RGB值就并不是(0.5, 0.5, 1)而是根据alpha混合过的(0.9, 0.9, 1),也就是右上那种明显更亮的错误的“法线贴图”,那么如何避免这种情况呢?

  • 不要用你计算机自带的画图工具打开这些图片,特别是“画图”这个工具,因为它们会直接破坏图层和通道,最好使用专业的美术工具,例如PS
  • 避免使用.jpg后缀格式的图片,.jpg是有损压缩,如果你得到的是个jpg格式的图片,可能自打一开始这个纹理就不是正常途径获得的,不是美术制作的直接成品。例如我在一个教程中看到了这张图片,但是他没有分享出他的资源,于是我用起了QQ截图把它截了下来当成了我的资源,甚至还分享给别人……这就贼离谱,堪比枪版电影,但是电影还能看图却不一定能用(低分辨率+有损+没有通道和图层,也没有Alpha属性等等)
  • 接上,从一些3D网站上下载到的资源或者一些群里别人给你的资源也有可能是上面说的那种“垃圾资源”,而且是莫得救的,然而事实上,这种资源反而占大多数,甚至还有贴图丢失的,对于这种,只能祝福凑活凑活勉强能用吧,但要是法线贴图可能就真GameOver了(充钱使你更强,大部分好的资源都是需要收费的)

​​​​​​​如果你只是拿来当放射光贴图或者漫反射贴图,甚至只是拿来存着看、当头像等等那确实问题不大,最多只是看起来模糊一点罢了

换句话说:你给一张图片打上了马赛克,那么这张图片就被永久破坏了,但事实上,如果一张图片看起来非常的正常,它也有可能已经被“永久破坏”了,最简单的:透明底和白底就是两个完全不同的概念,当然如果只是在白色的背景中使用,看起来就没有差。

  • 使用系统自带的“画图”工具点击保存是最简单的破坏方式,使用截图工具其次
  • 有些确实可以通过精湛的PS技巧恢复,但是也永远没有第一手图片的效果了,还特别的耗费精力

这里给一个最简单的例子,如何将上面的右图转换成左图:

①用PS打开,可以看到你的图片有黑白相间的格子,如果有那就对了,说明是正确的资源,没有就有可能是上面所说的“垃圾资源”,这个黑白格子意味着你的图片有Alpha通道并且非透明度并非100%

之后点击分离通道,理应可以得到下面的4个通道:

这下应该就能看明白了,只需要保留前3个通道,第四个透明区域去除!

再次合并通道,但是模式从多通道改成RGB通道,点击确定搞定!

这只是其中一种方法,当然这都属于美术的范畴了,不再属于程序技术的范畴,就不再多讲了

猜你喜欢

转载自blog.csdn.net/Jaihk662/article/details/108102673