Mesh
Model-Loading/Mesh
有了Assimp,我们可以将许多不同的模型加载到应用程序中,但是加载之后它们都存储在Assimp的数据结构中。我们最终想要的是将数据转换成OpenGL能理解的格式,这样我们就能呈现对象。我们从上一章了解到网格表示一个可绘制实体,所以让我们从定义一个我们自己的网格类开始。
让我们回顾一下到目前为止我们学过的关于网格的最小数据应该是什么。一个网格至少需要一组顶点,其中每个顶点包含一个位置向量、一个法向量和一个纹理坐标向量。网格还应该包含索引绘图的索引,以及材质数据(漫反射/高光贴图)。
现在我们设置了网格类的最小要求,我们可以在OpenGL中定义一个顶点:
struct Vertex {
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
};
我们将每个必需的顶点属性存储在一个名为vertex的结构中。在顶点结构的旁边,我们也想组织纹理数据在纹理结构:
struct Texture {
unsigned int id;
string type;
};
我们存储纹理的id和它的类型,例如,漫反射纹理或镜面纹理。
知道了一个顶点和一个纹理的实际表示,我们可以开始定义网格类的结构:
class Mesh {
public:
// mesh data
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> textures;
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures);
void Draw(Shader &shader);
private:
// render data
unsigned int VAO, VBO, EBO;
void setupMesh();
};
如您所见,这个类并不太复杂。在构造函数中,我们为网格提供所有必要的数据,在setupMesh函数中初始化缓冲区,最后通过draw函数绘制网格。注意,我们给了绘制函数一个着色器;通过将着色器传递给网格,我们可以在绘制之前设置一些uniform(比如将采样器链接到纹理单位)。
构造函数的函数内容非常简单。我们只需用构造函数对应的参数变量设置类的公共变量。我们也在构造函数中调用setupMesh函数:
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures)
{
this->vertices = vertices;
this->indices = indices;
this->textures = textures;
setupMesh();
}
没什么特别的。现在让我们深入研究setupMesh函数。
Initialization
多亏了构造函数,我们现在有了可以用于渲染的大网格数据列表。我们需要设置适当的缓冲区,并通过顶点属性指针指定顶点着色器布局。到目前为止,你应该对这些概念没有问题,但我们已经添加了一点,这一次在struct中引入顶点数据:
void setupMesh()
{
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
&indices[0], GL_STATIC_DRAW);
// vertex positions
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
// vertex normals
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
// vertex texture coords
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));
glBindVertexArray(0);
}
代码与您预期的没有太大的不同,但是在顶点结构的帮助下使用了一些小技巧。
结构体在c++中有一个很好的特性,即它们的内存布局是顺序的。也就是说,如果我们要将一个结构表示为一个数据数组,它将只包含按顺序排列的结构变量,这将直接转换为一个float(实际上是字节)数组,这是我们想要用于数组缓冲区的数组。例如,如果我们有一个填充的顶点结构,它的内存布局将等于:
Vertex vertex;
vertex.Position = glm::vec3(0.2f, 0.4f, 0.6f);
vertex.Normal = glm::vec3(0.0f, 1.0f, 0.0f);
vertex.TexCoords = glm::vec2(1.0f, 0.0f);
// = [0.2f, 0.4f, 0.6f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f];
由于这个有用的属性,我们可以直接传递一个指针到一个大列表的顶点结构作为缓冲区的数据,它们完美地转化为glBufferData期望的参数:
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), vertices[0], GL_STATIC_DRAW);
当然,也可以在结构上使用sizeof操作符来获得适当的字节大小。这应该是32字节(8个浮点数*每个4字节)。
struct的另一个重要用途是一个名为offsetof(s,m)的预处理器指令,它的第一个参数是struct,第二个参数是struct的变量名。该宏返回该变量从结构开始时的字节偏移量。这非常适合定义glVertexAttribPointer函数的偏移量参数:
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
现在使用宏的偏移量来定义偏移量,在这种情况下,它将法向量的字节偏移量设置为等于结构中normal属性的字节偏移量,该结构为3浮点数,因此为12字节。
使用这样的结构不仅可以让我们获得更可读的代码,还可以方便地扩展结构。如果我们想要另一个顶点属性,我们可以简单地将它添加到结构中,由于它的灵活性,渲染代码不会中断。
Rendering
我们需要为Mesh类定义的最后一个函数是它的绘制函数。在渲染网格之前,我们首先想在调用glDrawElements之前绑定适当的纹理。然而,这有点困难,因为我们不知道从一开始网格有多少(如果有的话)纹理和它们可能有什么类型。那么我们如何在着色器中设置纹理单元和采样器呢?
为了解决这个问题,我们将假定一个特定的命名约定:每个漫反射纹理命名为texture_diffuseN,每个高光纹理命名为texture_specularN,其中N是1到允许的最大纹理采样数量之间的任意数字。假设我们有3个漫反射纹理和2个镜面纹理,它们的纹理采样器应该被调用:
uniform sampler2D texture_diffuse1;
uniform sampler2D texture_diffuse2;
uniform sampler2D texture_diffuse3;
uniform sampler2D texture_specular1;
uniform sampler2D texture_specular2;
通过这个约定,我们可以在着色器中定义我们想要的任意多的纹理采样器(直到OpenGL的最大值),如果一个网格确实包含了(这么多)纹理,我们知道它们的名字会是什么。通过这个约定,我们可以在一个单一的网格上处理任意数量的纹理,着色器开发者可以通过定义适当的采样器来自由地使用任意数量的纹理。
像这样的问题有很多解决方案,如果你不喜欢这个特殊的解决方案,那就取决于你的创造性,想出你自己的方法。
由此产生的绘图代码为:
void Draw(Shader &shader)
{
unsigned int diffuseNr = 1;
unsigned int specularNr = 1;
for(unsigned int i = 0; i < textures.size(); i++)
{
glActiveTexture(GL_TEXTURE0 + i); // activate proper texture unit before binding
// retrieve texture number (the N in diffuse_textureN)
string number;
string name = textures[i].type;
if(name == "texture_diffuse")
number = std::to_string(diffuseNr++);
else if(name == "texture_specular")
number = std::to_string(specularNr++);
shader.setFloat(("material." + name + number).c_str(), i);
glBindTexture(GL_TEXTURE_2D, textures[i].id);
}
glActiveTexture(GL_TEXTURE0);
// draw mesh
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
}
我们首先计算每个纹理类型的n个组件,并将其连接到纹理的类型字符串以获得适当的uniform名称。然后我们找到合适的采样器,给它与当前活动纹理单元对应的位置值,并绑定纹理。这也是我们在绘制函数中需要着色器的原因。
我们还在最终的统一名称中添加了“material.”,因为我们通常将纹理存储在一个材质结构中(这可能在每个实现中有所不同)。
注意,我们在将漫反射计数器和镜面计数器转换为字符串时增加了它们。在c++中,increment调用:variable++按原样返回变量,然后增加变量,而++变量首先增加变量,然后返回它。在我们的例子中,传递给std::string的值是原始的计数器值。之后,该值将在下一轮中递增。
你可以在这里here. 找到Mesh类的完整源代码。
我们刚刚定义的Mesh类是我们在前面章节中讨论过的许多主题的一个抽象。在下一章中,我们将创建一个模型,作为几个网格对象的容器,并实现Assimp的加载接口。