日期:2025年6月18日标签:ComputerGraphics

Opengl shaders and buffer #

对 opengl 中 shader 和 buffer 的一些理解。

NDC(Normalized Device Coordinates) #

在 OpenGL 的渲染管线中,模型空间的顶点经过一系列变换(模型变换、视图变换、投影变换)后,会被转换到一个统一的坐标系统,这个坐标系统就是规范化设备坐标系(Normalized Device Coordinates)。它是一个三维的坐标系统,x、y、z 的范围是 [-1, +1]

只有在 NDC 范围内的坐标才会最终被显示在屏幕上,这个过程通常由 opengl 自动进行的,开发者只需要调用 glViewPort() 方法即可。

Vertices 和 VBO #

顶点着色器输出的坐标是基于 NDC 的,在 [-1, +1] 之外的坐标会被裁剪掉,最终也不会显示在屏幕上。

我们首先准备一份顶点数据(在 xy 平面上的等边三角形):

float verticies[] = 
{
    -0.5f, -0.5f, 0.0f, // (x, y, z)
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
}

有了数据,我们需要创建一个内存存储这些数据,并将这些数据发送到 GPU。这就是 VBO(Vertex Buffer Object)存在的意义,VBO 主要是用于在 GPU 上创建存储形状或顶点数据的内存。通过使用 VBO,可以一次性将大量顶点数据发送到 GPU,避免了逐个发送所带来的等待时间,提高了效率。它的作用类似于一次性将12块饼干放入烤箱烘烤,而不是每次只放一块,等待烘烤完成后再放下一块,从而加快整体处理速度。

下面的代码演示了如何创建一个 VBO 存储顶点数据,并发送给 GPU。

unsigned int VBO;      // decalre buffer
glGenBuffers(1, &VBO); // OpenGL function, Generate Buffers
            // --- PARAMETERS ---
            // assign a unique ID number 1 to VBO
            // VBO refrence (location in memory -- C++ term) 

/*
NOTES: There are many types of buffer objects which can be found here
    https://www.khronos.org/opengl/wiki/Buffer_Object
    OpenGL allows for several buffers at the same time, but
    they must have different buffer types.
    For clarity you have multiple buffers of the same type 
    declared and defined, but you would have to switch between them
    in order to use them.
*/
glBindBuffer(GL_ARRAY_BUFFER, VBO); // OpenGL function, Bind Buffer
                  // --- PARAMETERS --- 
                  // Array Buffer type
                  // assign to buffer object VBO

/*
NOTES: There are a few types of "usage", but more often than not,
    these three are the most common: GL_STREAM_DRAW, GL_STATIC_DRAW,
    GL_DYNAMIC_DRAW. As to what each of them mean (in respective order):
    data remains the same and GPU will use it a few times,
    data remains the same and GPU will use it many times,
    data changes constantly and GPU will use it a lot.
    
    All other "usage" types and meaning can be found here
    https://registry.khronos.org/OpenGL-Refpages/gl4/html/glBufferData.xhtml
*/
// OpenGL function, Buffer Data                  
glBufferData( GL_ARRAY_BUFFER,      // using the VBO with Array Buffer type
       sizeof(verticies),    // bytes of memory, array of verticies
       vertices,             // data to send to GPU, array of verticies 
       GL_STATIC_DRAW        // usage, tell GPU data doesn't change often
      );

Vertex Shader #

显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)。现代 opengl 要求开发至少设置一个顶点着色器和片段着色器。

顶点着色器的作用是对 vbo 中的每个顶点数据进行变换(transform),转换成 NDC 坐标系中的坐标。

在顶点着色器中经常用到的变换有:模型变换、视口变换、透视变换和标准化,关于坐标系统以及变换的了解,可以阅读这篇文章

shader 使用一种类 C 语言编写,即 GLSL。

如何创建链接着色器,以及编写 GLSL,可以看这篇文章教学:shaders

下面的代码演示了创建编译一个顶点着色器:

// shaders must be loaded as code strings
// NOTE: it is possible to load it in from a different file 
const char *vertexShaderSource = "#version 330 core\n"
"layout (location = 0) in vec3 aPos;\n"
"void main()\n"
"{\n"
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
"}\0";

unsigned int vertexShader; // vertex shader ID holder
vertexShader = glCreateShader(GL_VERTEX_SHADER); // assign/get ID
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL); // set shader object
glCompileShader(vertexShader); // compile shader

/* VERTEX SHADER COMPILER CHECKER */
int success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE, STATUS, &success);

if(!success)
{
 glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
 std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" 
      << infoLog 
      << std::endl;
} // else, was successful -- continue running code

Fragment Shader #

片段着色器的作用就是计算最终输出到屏幕上的每个像素的颜色。用 R、G、B、A 四个值代替颜色,每个值的范围为 [0, 1],与通常使用的 [0, 255] 范围等比映射的,0 = 0,1 = 255。片段着色器也是用 glsl 编写,输出是 FragColor 变量。

# version 330 core  // version 3.3 core-profile
out vec4 FragColor; // vec4 FragColor; // output

void main()
{
 FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f); // RGBA = (1, .5, .2, 1) 
}

创建编译一个片段着色器过程与顶点着色器类似:

const char* fragmentShaderSource = "# version 330 core\n"
"out vec4 FragColor;\n"
"void main()\n"
"{\n"
" FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
"}\0";

unsigned int fragmentShader; // fragment shader ID holder
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); // assign ID
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL); // set fragment
glCompileShader(fragmentShader); // compile fragment shader

/* FRAGMENT SHADER COMPILER CHECKER */
GLint success;
GLchar infoLog[512];
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);

if (!success)
{
    glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n"
              << infoLog
              << std::endl;
}

Shader Program #

有了片段着色器和顶点着色器之后,需要创建着色器程序(Shader Program),着色器程序将所有着色器链接起来,将前一个着色器的输出作为下一个着色器的输入,按序执行。

unsigned int shaderProgram; // shader ID holder
shaderProgram = glCreateProgram(); // assign shader ID
glAttatchShader(shaderProgram, vertexShader); // (1) vertex shader
glAttatchShader(shaderProgram, framentShader);// (2) fragmentShader
glLinkProgram(shaderProgram); // combine/link shaders

/* SHADER PROGRAM COMPILER CHECKER */
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success)
{
 glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
 std::cout << "ERROR::SHADER PROGRAM::COMPILATION_FAILED\n"
              << infoLog
              << std::endl;
}

glUseProgram(shaderProgram); // every render now uses this shader object

// cleanup -- delete shader objects once linked (saves on memory)
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

VAO #

一个 3D 场景通常会渲染很多(成千上万)个物体,所以程序中必然存在很多个不同的顶点数据,渲染一个 obj,我们需要告诉 gpu 如何解析这些顶点数据。代码会是下面这样:

void drawMesh(Mesh[] meshes){
    foreach(mesh in meshes){        
        glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo);
        glVertexAttribPointer(posAttrLoc, 3, GL_FLOAT, false, sizeof(Vertex), mesh.vboOffset + offsetof(Vertex, pos));
        glVertexAttribPointer (normalAttrLoc, 3, GL_FLOAT, false, sizeof(Vertex), mesh.vboOffset + offsetof(Vertex, normal));
        //...
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mesh.ebo);

        glDrawElements(GL_TRIANGLES, mesh.vertexCount, GL_UNSIGNED_INT, mesh.indexOffset);
    }
}

每个 mesh 的绘制,都需要告诉 opengl 如何解析顶点数据,以及 ebo(ibo, 索引缓冲对象)绑定。

有了 vao 后,绘制过程可以变成这样:

//done once per mesh on load
void prepareMeshForRender(Mesh mesh){
    glBindVertexArray(mesh.vao);
    glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo);

    glVertexAttribPointer (posAttrLoc, 3, GL_FLOAT, false, sizeof(Vertex), mesh.vboOffset + offsetof(Vertex, pos));//will associate mesh.vbo with the posAttrLoc
    glEnableVertexAttribArray(posAttrLoc);

    glVertexAttribPointer (normalAttrLoc, 3, GL_FLOAT, false, sizeof(Vertex), mesh.vboOffset + offsetof(Vertex, normal));
    glEnableVertexAttribArray(normalAttrLoc);

    //...

    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mesh.ebo); //this binding is also saved.
    glBindVertexArray(0);
}

void drawMesh(Mesh[] meshes){
    foreach(mesh in meshes){        
        glBindVertexArray(mesh.vao);
        glDrawElements(GL_TRIANGLES, mesh.vertexCount, GL_UNSIGNED_INT, mesh.indexOffset);
    }
}

只需要在,绘制前调用 glBindVertexArray,切换绑定 vao 即可。

简而言之,vao 的作用就是缓存顶点数据的 vbo 配置和 ebo 配置,以便后续绘制时不需要重复进行配置

关于 vbo、ebo(ibo,下面会简单的介绍一下)的数据配置以及解析,可以阅读这篇教程:learnopengl hello-triangle

EBO #

这里也简单介绍一下 EBO,它是为了减少顶点数据内存的一个重要手段。如果你要绘制一个矩形 ABCD(从左上角开始,顶点逆时针顺序为 ABCD),需要准备的顶点数据如下:

float vertices = {
    // the first triangle
    Ax, Ay, Az,
    Bx, By, Bz,
    Cx, Cy, Cz,
    // the second triangle
    Ax, Ay, Az,
    Cx, Cy, Cz,
    Dx, Dy, Dz,  
}

可以看到绘制两个三角形,数据中有两个顶点 A 和 C 的数据重复定义了,我们希望顶点数据可以尽量少:

float vertices = {
    Ax, Ay, Az,
    Bx, By, Bz,
    Cx, Cy, Cz,
    Dx, Dy, Dz
}

这就是 EBO 的作用,EBO 存储了顶点的索引,告诉 gpu 绘制按照索引点顺序绘制:

float verticesIndex = {
    // the first triangle 
    0, 1, 2,
    // the second triangle
    0, 1, 3
}

结合 verticesIndex 和 去重后的 vertices,gpu 仍然能够绘制出一个矩形。

多 shader 绘制 #

如果有两个 mesh 需要绘制,两个 mesh 使用的是不同的 shader,在绘制过程中需要使用 glUseProgram 切换 mesh 绘制。

需要注意的点有:

  • 每个 shader 程序(program)只能一次激活使用一个,绘制不同对象时需要切换 glUseProgram。
  • 每个对象绑定自己的顶点数据(VAO/VBO)和 shader 程序。
  • 先激活对应 shader,绑定对应 VAO,调用绘制指令;然后切换另一个shader,绑定另一个VAO,再调用绘制指令。
  • 多数情况下,为了代码整洁,会为每个对象分别创建VAO和Program。

可能的伪代码:

// prepare shader program
GLuint programTriangle = createShaderProgram(vertexShaderSrc1, fragmentShaderSrc1);
GLuint programSquare = createShaderProgram(vertexShaderSrc2, fragmentShaderSrc2);

// prepare VAO / VBO
GLuint vaoTriangle = createTriangleVAO();
GLuint vaoSquare = createSquareVAO();

while (!windowShouldClose) {
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    // 1. draw triangle
    glUseProgram(programTriangle);
    glBindVertexArray(vaoTriangle);
    // set uniform
    // glUniform*(...)
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // 2. draw texture
    glUseProgram(programSquare);
    glBindVertexArray(vaoSquare);
    // set uniform
    // glUniform*(...)
    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

    glfwSwapBuffers(window);
    glfwPollEvents();
}

Reference #

目录