1
0
mirror of https://github.com/LearnOpenGL-CN/LearnOpenGL-CN.git synced 2025-08-23 04:35:28 +08:00
Files
LearnOpenGL-CN/docs/03 Model Loading/02 Mesh.md
2016-08-27 22:26:26 +08:00

9.9 KiB
Raw Blame History

网格

原文 | Mesh ---|--- 作者 | JoeyDeVries 翻译 | Django 校对 | Geequlim

使用Assimp可以把多种不同格式的模型加载到程序中但是一旦载入它们就都被储存为Assimp自己的数据结构。我们最终的目的是把这些数据转变为OpenGL可读的数据才能用OpenGL来渲染物体。我们从前面的教程了解到一个网格(Mesh)代表一个可绘制实体,现在我们就定义一个自己的网格类。

先来复习一点目前学到知识考虑一个网格最少需要哪些数据。一个网格应该至少需要一组顶点每个顶点包含一个位置向量一个法线向量一个纹理坐标向量。一个网格也应该包含一个索引绘制用的索引以纹理diffuse/specular map形式表现的材质数据。

现在为了在OpenGL中设置一个满足最低需求的网格类我们定义一个顶点:

struct Vertex
{
    glm::vec3 Position;
    glm::vec3 Normal;
    glm::vec2 TexCoords;
};

我们把每个需要的向量储存到一个叫做Vertex的结构体中,它被用来索引每个顶点属性。另外除了Vertex结构体外,我们也希望组织纹理数据,所以我们定义一个Texture结构体:

struct Texture
{
    GLuint id;
    String type;
};

我们储存纹理的id和它的类型比如漫反射贴图或者镜面贴图。

知道了顶点和纹理的实际表达,我们可以开始定义网格类的结构:

class Mesh
{
Public:
    vector<Vertex> vertices;
    vector<GLuint> indices;
    vector<Texture> textures;
    Mesh(vector<Vertex> vertices, vector<GLuint> indices, vector<Texture> texture);
    Void Draw(Shader shader);
 
private:
    GLuint VAO, VBO, EBO;
    void setupMesh();
}

如你所见这个类一点都不复杂,构造方法里我们初始化网格所有必须数据。在setupMesh函数里初始化缓冲。最后通过Draw函数绘制网格。注意,我们把shader传递给Draw函数。通过把shader传递给Mesh在绘制之前我们设置几个uniform就像链接采样器到纹理单元

构造函数的内容非常直接。我们简单设置类的公有变量,使用的是构造函数相应的参数。我们在构造函数中也调用setupMesh函数:

Mesh(vector<Vertex> vertices, vector<GLuint> indices, vector<Texture> textures)
{
    this->vertices = vertices;
    this->indices = indices;
    this->textures = textures;
 
    this->setupMesh();
}

这里没什么特别的,现在让我们研究一下setupMesh函数。

初始化

现在我们有一大列的网格数据可用于渲染这要感谢构造函数。我们确实需要设置合适的缓冲通过顶点属性指针vertex attribute pointers定义顶点着色器layout。现在除了将顶点数据传入结构体以外你应该对其它概念很熟悉

void setupMesh()
{
    glGenVertexArrays(1, &this->VAO);
    glGenBuffers(1, &this->VBO);
    glGenBuffers(1, &this->EBO);
  
    glBindVertexArray(this->VAO);
    glBindBuffer(GL_ARRAY_BUFFER, this->VBO);
 
    glBufferData(GL_ARRAY_BUFFER, this->vertices.size() * sizeof(Vertex), 
                 &this->vertices[0], GL_STATIC_DRAW);  
 
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, this->EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, this->indices.size() * sizeof(GLuint), 
                 &this->indices[0], GL_STATIC_DRAW);
 
    // 设置顶点坐标指针
    glEnableVertexAttribArray(0); 
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 
                         (GLvoid*)0);
    // 设置法线指针
    glEnableVertexAttribArray(1); 
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 
                         (GLvoid*)offsetof(Vertex, Normal));
    // 设置顶点的纹理坐标
    glEnableVertexAttribArray(2); 
    glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), 
                         (GLvoid*)offsetof(Vertex, TexCoords));
 
    glBindVertexArray(0);
}

这里的代码和你设想的没什么特别不同的地方,但是向Vertex结构体传入数据需要有一些小技巧。

C++的结构体有一个重要的属性,那就是在内存中它们是连续的。如果我们用结构体表示一列数据,这个结构体只包含结构体的连续的变量,它就会直接转变为一个float实际上是byte数组我们就能用于一个数组缓冲array buffer中了。比如如果我们填充一个Vertex结构体,它在内存中的排布等于:

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];

感谢这个有用的特性,我们能直接把一个作为缓冲数据的一大列Vertex结构体的指针传递过去,它们会翻译成glBufferData能用的参数:

glBufferData(GL_ARRAY_BUFFER, this->vertices.size() * sizeof(Vertex), 
             &this->vertices[0], GL_STATIC_DRAW);

自然地,sizeof函数也可以使用于结构体来计算字节类型的大小。它应该是32字节8float * 4

一个预处理指令叫做offsetof(s,m)把结构体作为它的第一个参数第二个参数是这个结构体名字的变量。这是结构体另外的一个重要用途。函数返回这个变量从结构体开始的字节偏移量offset。这对于定义glVertexAttribPointer函数偏移量参数效果很好:

glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), 
                     (GLvoid*)offsetof(Vertex, Normal));

偏移量现在使用offsetof函数定义了,在这个例子里,设置法线向量的字节偏移量等于法线向量在结构体的字节偏移量,它是3float也就是12字节一个float占4字节。注意我们同样设置步长参数等于Vertex结构体的大小。

使用一个像这样的结构体,不仅能提供可读性更高的代码同时也是我们可以轻松的扩展结构体。如果我们想要增加另一个顶点属性,我们把它可以简单的添加到结构体中,由于它的可扩展性,渲染代码不会被破坏。

渲染

我们需要为Mesh类定义的最后一个函数是它的Draw函数。在真正渲染前我们希望绑定合适的纹理然后调用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;

使用这样的惯例我们能定义我们在着色器中需要的纹理采样器的数量。如果一个网格真的有这么多纹理我们就知道它们的名字应该是什么。这个惯例也使我们能够处理一个网格上的任何数量的纹理通过定义合适的采样器开发者可以自由使用希望使用的数量虽然定义少的话就会有点浪费绑定和uniform调用了

像这样的问题有很多不同的解决方案,如果你不喜欢这个方案,你可以自己创造一个你自己的方案。 最后的绘制代码:

void Draw(Shader shader) 
{
    GLuint diffuseNr = 1;
    GLuint specularNr = 1;
    for(GLuint i = 0; i < this->textures.size(); i++)
    {
        glActiveTexture(GL_TEXTURE0 + i); // 在绑定纹理前需要激活适当的纹理单元
        // 检索纹理序列号 (N in diffuse_textureN)
        stringstream ss;
        string number;
        string name = this->textures[i].type;
        if(name == "texture_diffuse")
            ss << diffuseNr++; // 将GLuin输入到string stream
        else if(name == "texture_specular")
            ss << specularNr++; // 将GLuin输入到string stream
        number = ss.str(); 
 
        glUniform1f(glGetUniformLocation(shader.Program, ("material." + name + number).c_str()), i);
        glBindTexture(GL_TEXTURE_2D, this->textures[i].id);
    }
    glActiveTexture(GL_TEXTURE0);
 
    // 绘制Mesh
    glBindVertexArray(this->VAO);
    glDrawElements(GL_TRIANGLES, this->indices.size(), GL_UNSIGNED_INT, 0);
    glBindVertexArray(0);
}

这不是最漂亮的代码但是这部分归咎于C++转换类型时的丑陋,比如intstring时。我们首先计算N-元素每个纹理类型把它链接到纹理类型字符串来获取合适的uniform名。然后查找合适的采样器位置给它位置值对应当前激活纹理单元绑定纹理。这也是我们需要在Draw方法是用shader的原因。我们添加material.到作为结果的uniform名因为我们通常把纹理储存进材质结构体对于每个实现也许会有不同

!!! Important

注意,当我们把`diffuse`和`specular`传递到字符串流(`stringstream`的时候计数器会增加在C++自增叫做:变量++它会先返回自身然后加1而++变量先加1再返回自身我们的例子里我们先传递原来的计数器值到字符串流然后再加1下一轮生效。

你可以从这里得到Mesh类的源码

Mesh类是对我们前面教程里讨论过的很多话题的简洁抽象。在下面的教程里我们会创建一个用作盛放多个网格物体的容器模型真正的实现Assimp的加载接口。