【OpenGL】LearnOpenGL学习笔记25 - 法线贴图 NormalMap

上接:https://blog.csdn.net/weixin_44506615/article/details/151876995?spm=1001.2014.3001.5501

完整代码:https://gitee.com/Duo1J/learn-open-gl | https://github.com/Duo1J/LearnOpenGL

法线贴图 (NormalMap)

换回之前的背包场景,观察之前绘制的背包可以发现,模型的表面非常的平坦

比如斧头的木把手应该是粗糙不平的,可现在看起来很光滑

如果以光线的视角来看这个问题可以发现,表面之所以看起来这么平坦,是模型表面完全平行规整的法线导致的,如下图所示 (图片来自于LearnOpenGL)

所以要做出粗糙不平的效果,我们可以对每个片段所对应的法线进行改变,将这种改变的xyz坐标存储在纹理中的rgb通道,这样的问题就叫 法线贴图 (Normal Map) 或者 凹凸贴图 (Bump Map)

这是一个砖墙的法线贴图,可以看见其大部分都是偏蓝色的,这是因为所有法线的指向都是偏向Z轴的 (0, 0, 1)

在之前的背包资源文件夹中有一张背包的法线贴图,但我们不能直接对法线贴图进行采样然后应用法线,这是因为我们的法向量 是处于世界空间,它不总是Z轴垂直于表面,所以我们需要进行空间变换,这会引用一个新的空间:切线空间

切线空间

切线空间是位于三角形表面上的空间,其法线永远指向正Z方向,另外两个轴为T (切线 Tangent)B (副切线 Bitangent) ,加上 N (法线 Normal) 共称 TBN
对于简单物体我们可以自行计算TBN,但对于较为复杂的物体计算较为麻烦,之前加载模型使用的Assimp库 可以帮我们来计算切线,接下来修改一下之前的Model、Mesh、Vertex、Texture类
Vertex.h

增加切线和副切线,由于后续绑定顶点时需要连续的结构,这里需要注意顺序

cpp 复制代码
#pragma once

#include <glm.hpp>

/**
* 顶点
*/
struct Vertex
{
	/**
	* 位置
	*/
	glm::vec3 position;

	/**
	* 法向量
	*/
	glm::vec3 normal;

	/**
	* UV
	*/
	glm::vec2 texCoord;

	/**
	* 切线
	*/
	glm::vec3 tangent;

	/**
	* 副切线
	*/
	glm::vec3 bitangent;
};

Model.cpp

cpp 复制代码
void Model::LoadModel(std::string path)
{
	// ...
	// aiProcess_CalcTangentSpace: 计算切线空间
	const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace);
	// ...
}

Mesh Model::ProcessMesh(aiMesh* mesh, const aiScene* scene)
{
	// ...
	// 切线、副切线
	if (mesh->HasTangentsAndBitangents())
	{
		vertex.tangent = glm::vec3(mesh->mTangents[i].x, mesh->mTangents[i].y, mesh->mTangents[i].z);
		vertex.bitangent = glm::vec3(mesh->mBitangents[i].x, mesh->mBitangents[i].y, mesh->mBitangents[i].z);
	}
	// ...
	// 加载材质和纹理
	if (mesh->mMaterialIndex >= 0)
	{
		// ...
		// 加载法线贴图
		std::vector<Texture> normalMaps = LoadMaterialTextures(material, aiTextureType_HEIGHT, TextureType::NORMAL);
		textures.insert(textures.end(), normalMaps.begin(), normalMaps.end());
	}
}

Mesh.cpp

cpp 复制代码
void Mesh::Draw(const Shader& shader, int instanceCnt)
{
	// ...
	unsigned int normalNum = 0;
	// ...
	else if (texture.type == TextureType::NORMAL)
	{
		number = std::to_string(++normalNum);
	}
}

void Mesh::CreateBuffer()
{
	// ...
	
	// 外部临时绑定了Instance Offset,先跳过3

	glEnableVertexAttribArray(4);
	glVertexAttribPointer(4, 3, GL_FLOAT, false, sizeof(Vertex), (void*)offsetof(Vertex, tangent));

	glEnableVertexAttribArray(5);
	glVertexAttribPointer(5, 3, GL_FLOAT, false, sizeof(Vertex), (void*)offsetof(Vertex, bitangent));
}

Texture.cpp

cpp 复制代码
std::string Texture::GetTypeName()
{
	if (type == TextureType::DIFFUSE)
	{
		return "tex_diffuse";
	}
	else if (type == TextureType::SPECULAR)
	{
		return "tex_specular";
	}
	else if (type == TextureType::NORMAL)
	{
		return "tex_normal";
	}
	else
	{
		assert(false);
		return "";
	}
}

切线空间准备完毕,接下来可以开始应用法线贴图了

应用法线贴图

对于切线空间的转换有两种路径,先来尝试第一种,将采样后的法线左乘TBN矩阵转换到世界空间来使用
VertexShader.glsl

cpp 复制代码
#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in vec3 offset;
// 切线
layout (location = 4) in vec3 aTangent;
// 副切线
layout (location = 5) in vec3 aBitangent;

out vec3 FragPos;
out vec3 Normal;
out vec2 TexCoords;

uniform mat4 model;
layout (std140) uniform Matrices
{
    mat4 view;
    mat4 projection;
};

out VS_OUT {
    vec3 FragPos;
    vec3 Normal;
    vec2 TexCoords;
    // 输出TBN矩阵
    mat3 TBN;
    vec3 Debug;
} vs_out;

void main()
{
    gl_Position = projection * view * model * vec4(aPos + offset, 1.0);
    FragPos = vec3(model * vec4(aPos + offset, 1.0));
    vs_out.FragPos = FragPos;
    Normal = mat3(transpose(inverse(model))) * aNormal;
    vs_out.Normal = Normal;
    TexCoords = aTexCoords;
    vs_out.TexCoords = aTexCoords;

    // 计算TBN矩阵
    vec3 T = normalize(vec3(model * vec4(aTangent,   0.0)));
    vec3 B = normalize(vec3(model * vec4(aBitangent, 0.0)));
    vec3 N = normalize(vec3(model * vec4(aNormal,    0.0)));
    vs_out.TBN = mat3(T, B, N);
}

FragmentShader.glsl

cpp 复制代码
// ...
struct Material {
    sampler2D tex_diffuse1;
    sampler2D tex_diffuse2;
    // ...
    sampler2D tex_specular1;
    sampler2D tex_specular2;
    // ...
    // 增加法线贴图
    sampler2D tex_normal1;
    sampler2D tex_normal2;
    // ...
    float shininess;
};
// ...
in VS_OUT {
    vec3 FragPos;
    vec3 Normal;
    vec2 TexCoords;
    mat3 TBN;
    vec3 Debug;
} fs_in;
// ...
void main()
{
    // 采样法线贴图
    vec3 normal = texture(material.tex_normal1, fs_in.TexCoords).rgb;
    // [-1, 1]转换到[0, 1]
    normal = normalize(normal * 2 - 1);
    // 左乘TBN矩阵转换到世界空间
    normal = normalize(fs_in.TBN * normal);
	// ...
}

编译运行,可以发现木头把手有了凹凸不平的粗糙感

第二种方式则是反过来,我们使用TBN的逆矩阵将所有相关的世界空间向量转换为切线空间向量
VertexShader.glsl

cpp 复制代码
// ...
out VS_OUT {
    vec3 FragPos;
    vec3 Normal;
    vec2 TexCoords;
    mat3 TBN;
    vec3 TangentViewPos;
    vec3 TangentFragPos;
    vec3 Debug;
} vs_out;
// ...
void main()
{
	// ...
    // 计算TBN矩阵
    vec3 T = normalize(vec3(model * vec4(aTangent,   0.0)));
    vec3 B = normalize(vec3(model * vec4(aBitangent, 0.0)));
    vec3 N = normalize(vec3(model * vec4(aNormal,    0.0)));
    // 这里不使用inverse是因为它是一个正交矩阵
    // 正交矩阵的逆矩阵和转置矩阵相等,但是transpose效率更高
    mat3 TBN = transpose(mat3(T, B, N));
    // 还是将TBN交给片段着色器用来计算lightPos,就先不移到顶点着色器了
    vs_out.TBN = TBN;
    vs_out.TangentViewPos  = TBN * viewPos;
    vs_out.TangentFragPos  = TBN * vec3(model * vec4(aPos + offset, 0.0));
}

FragmentShader.glsl

将用到的ViewPos、FragPos以及LightPos转换为切线空间向量

cpp 复制代码
// 光照函数中的light.position改为
// normalize(fs_in.TBN * light.position)

void main()
{
    // 采样法线贴图
    vec3 normal = texture(material.tex_normal1, fs_in.TexCoords).rgb;
    // [-1, 1]转换到[0, 1]
    normal = normalize(normal * 2 - 1);
    // 换为切线空间
    vec3 viewDir = normalize(fs_in.TangentViewPos - fs_in.TangentFragPos);

    // 平行光
    vec3 result = CalcDirectionalLight(dirLight, normal, viewDir);
    // 点光源
    for (int i = 0; i < POINT_LIGHT_NUM; i++)
    {
        result += CalcPointLight(pointLight[i], normal, viewDir, fs_in.TangentFragPos);
    }

    FragColor = vec4(result, 1.0);
}

编译运行,我们可以看见和第一种方法一样的效果

但是这样我们将计算挪到了顶点着色器 (LightPos的转换也可以提前到顶点着色器进行),而通常顶点着色器运行次数会小于片段着色器,这会带来性能的提升

此外,还有一个问题和对应的技巧

当在更大的网格上计算切线向量的时候,它们往往有很大数量的共享顶点,当法向贴图应用到这些表面时将切线向量平均化通常能获得更好更平滑的结果。这样做有个问题,就是TBN向量可能会不能互相垂直,这意味着TBN矩阵不再是正交矩阵了。法线贴图可能会稍稍偏移。
我们可以使用叫做格拉姆-施密特正交化过程(Gram-Schmidt process)的数学技巧,我们可以对TBN向量进行重正交化,这样每个向量就又会重新垂直了。在顶点着色器中我们这样做:

cpp 复制代码
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
// re-orthogonalize T with respect to N
T = normalize(T - dot(T, N) * N);
// then retrieve perpendicular vector B with the cross product of T and N
vec3 B = cross(T, N);

mat3 TBN = mat3(T, B, N)

完整代码可在顶部Git仓库中找到

下接:https://blog.csdn.net/weixin_44506615/article/details/151930651?spm=1001.2014.3001.5502

相关推荐
Duo1J2 小时前
【OpenGL】LearnOpenGL学习笔记26 - 视差贴图 Parallax Map
笔记·学习·图形渲染·贴图·着色器
da_vinci_x2 小时前
游戏UI告别“贴图”时代:用Adobe XD构建“活”的设计系统
游戏·ui·材质·贴图·游戏策划·游戏美术·pbr
风已经起了2 小时前
FPGA学习笔记——图像处理之亮度调节(乘法型)
图像处理·笔记·学习·fpga开发·fpga
能不能别报错2 小时前
K8s学习笔记(五) Velero结合minnio业务数据备份与恢复
笔记·学习·kubernetes
能不能别报错3 小时前
K8s学习笔记(六) K8s升级与节点管理
笔记·学习·kubernetes
今天也好累3 小时前
贪心算法之分数背包问题
c++·笔记·学习·算法·贪心算法
誰能久伴不乏4 小时前
Linux Shell 脚本:从零到进阶的实战笔记
linux·chrome·笔记
九年义务漏网鲨鱼4 小时前
等效学习率翻倍?梯度累积三连坑:未除以 accum_steps、调度器步进错位、梯度裁剪/正则标度错误(含可复现实验与修复模板)
python·深度学习·学习
ooolmf4 小时前
3.1.1话题,发布小说笔记20250923
笔记