随想    技术    关于    友链    返回普通版本

十分钟计算机图形学 II - GPU 与图形 API

上一章 中,我们实现了一个非常简单的软件渲染器。管中窥豹,可见一斑,我们已经大致理解了(至少是传统的、基于光栅化方法的)渲染器是如何工作的。只不过目前所有的工作还都是在 CPU 上进行的。在本文中,我们会讨论图形处理器 GPU 及其特性,并且研究图形 API 的发展历程,明白图形 API 是如何一步步发展成现在的样子的。

图形处理器 GPU

在上一章的代码中,我们可以注意到,有这么几个 for 循环:

    vs_outputs = map(vertex_shader, vertices)
    for i in 1:vertex_count
        vs_outputs[i].position /= vs_outputs[i].position.w
    end
        for y_pix in min_y_pix:max_y_pix
            for x_pix in min_x_pix:max_x_pix
                x = 2.0 * x_pix / width - 1.0
                y = 2.0 * y_pix / height - 1.0

                fragment = interpolate3(Vec2(x, y), v1, v2, v3)
                if isnothing(fragment)
                    continue
                end

                color = fragment_shader(fragment)
                framebuffer[y_pix, x_pix] = color
            end
        end

稍微总结一下,我们可以得出这种 for 循环的特点:

而 CPU 是为少量大型任务设计的,虽然 CPU 也可以利用多线程和 SIMD 等方式来加速处理,但面对浩如烟海的像素数量,CPU 也力不从心。这也就催生了专用的图形处理器,也就是 GPU

GPU 的特性

GPU 内部有大量的处理单元,每个处理单元都能相对独立地处理较为轻型的任务。这就使得 GPU 在并行处理方面具有很大的优势,并且刚好符合了上面提到的 for 循环的需求。不过,要妥善地利用 GPU 的能力,还是需要一些技巧的:

前两项优化的责任主要在程序员,例如程序员应该思考如何写出高性能的着色器代码,如何将任务分配给 GPU 处理单元等等。而最后一项优化则需要图形 API 予以配合。一般而言,在 CPU 和 GPU 之间高效地通信,需要:

图形 API 的设计会以各种方式影响这些优化的可能性,我们马上会看到这一点。

早期图形 API —— 以 OpenGL 1.0 为例

在上一章中我们略微窥探了 OpenGL 1.0 的 API:

glBegin(GL_TRIANGLES);
    glColor3f(1.0f, 0.0f, 0.0f);
    glVertex2f(-1.0f, -1.0f);

    glColor3f(0.0f, 1.0f, 0.0f);
    glVertex2f(1.0f, -1.0f);

    glColor3f(0.0f, 0.0f, 1.0f);
    glVertex2f(0.0f, 1.0f);
glEnd();

这种每次调用函数提交一点数据的方式称为 立即模式(immediate mode)。显然,我们很快就能发现这种 API 的另一个局限性:我们每次调用 glColor3f / glVertex2f,相当于只向 GPU 提交了几个字节的数据。而如果每次都要走 PCI-E 总线通信,最终的结果可能就是 GPU 绘图的时间只有一丁点,而传输数据的时间却占了大头。

这显然是不合理的,事实上图形驱动程序会在内部进行一些优化,例如将许多调用缓存起来,然后一次性传输。但驱动程序毕竟不可能知道用户程序到底在做什么,只能基于一些假设进行优化,而这些假设并不总是正确的。

OpenGL 的斗争之路

顶点数组

要一次性提交尽量多的数据,最容易想到的方式就是使用数组。OpenGL 首先引入的东西就是顶点数组(vertex array)

float positionArray[] = {
    -1.0f, -1.0f,
    1.0f, -1.0f,
    0.0f, 1.0f
};

float colorArray = {
    1.0f, 0.0f, 0.0f,
    0.0f, 1.0f, 0.0f,
    0.0f, 0.0f, 1.0f
};

// glVertex“2f” 是一次上传 2 个 GLfloat,而 glVertex“Pointer” 就是一次性上传一个 GLfloat 数组
glVertexPointer(2, GL_FLOAT, 0, positionArray);
// glColor“Pointer” 同理
glColorPointer(3, GL_FLOAT, 0, colorArray);

glDrawArrays(GL_TRIANGLES, 0, 3);

有了 glXxxPointer 这样的 API,就能一次性提交一组数据了。

顶点缓冲对象

有顶点数组还不够。在很多场景下,一个物体的顶点数据是不会变的,但用 glXxxPointer 这样的 API,我们还是需要每次绘制物体都提交一遍顶点数据。一个很直观的思路就是增加这样一个 API:它能够让我们把某一段数据直接上传到 GPU 上,让 GPU 返回给我们一个句柄,之后在绘制的时候只要提交这个句柄给 GPU,GPU 就会自动调动相应的数据。没错,这就是顶点缓冲对象(Vertex Buffer Object, VBO)

// 初始化阶段
int bufferId;
glGenBuffers(1, &bufferId); // 在 GPU 那边创建一个缓冲区,把句柄存储到 bufferId 中
// ...

// 数据上传
glBindBuffer(GL_ARRAY_BUFFER, bufferId); // 选择要操作的缓冲区
glBufferData(GL_ARRAY_BUFFER, sizeof(positionArray), positionArray, GL_STATIC_DRAW); // 上传数据
// ...

// 绘制阶段
glBindBuffer(GL_ARRAY_BUFFER, bufferId); // 选择要操作的缓冲区
glVertexPointer(2, GL_FLOAT, 0, 0); // 给原先传指针的参数传 0 (空指针),表示从缓冲区中读取数据
glDrawArrays(GL_TRIANGLES, 0, 3); // 直接调用之前存储在 GPU 上的数据进行绘制

// 在可编程管线中,使用 glVertexAttribPointer 代替 glVertexPointer,其他类似

顶点数组对象

有了 VBO,我们就可以把顶点数据存储在 GPU 上了。但是,我们还是需要每次调用 glXxxPointer 来告诉 GPU 顶点数据的格式。这样的话,我们就需要在绘制的时候,每次都要调用一次 glXxxPointer。如果绘制一个物体涉及很多组数据(顶点位置、颜色、纹理坐标、法线等),这样的调用就会很多,而这也会成为问题。解决的方法就是让一个东西来管理多个 VBO,这就是顶点数组对象(Vertex Array Object, VAO)

// 初始化阶段
int vaoId;
glGenVertexArrays(1, &vaoId); // 在 GPU 那边创建一个 VAO,把句柄存储到 vaoId 中

// 数据上传
glBindVertexArray(vaoId); // 选择要操作的 VAO
// 之后的所有操作都会被记录到这个 VAO 中
// ...

// 绘制阶段
glBindVertexArray(vaoId); // 选择要操作的 VAO
glDrawArrays(GL_TRIANGLES, 0, 3); // 直接调用之前存储在 GPU 上的数据进行绘制

实例化渲染

目前为止,渲染一个物体的问题我们已经解决的差不多了。但如果是多个物体怎么办?

不同的物体可能共享同一组顶点数据,但它们的位置、姿态(旋转)、缩放是不同的。在 OpenGL 的固定功能管线中,这些信息是通过一系列 API 调用来传送的:

glMatrixMode(GL_MODELVIEW); // 选择模型视图矩阵
glLoadIdentity(); // 重置矩阵为单位矩阵
glTranslatef(1.0f, 0.0f, 0.0f); // 平移
// 其他

而在可编程管线的时代,这些变换通常会由着色器代码来进行处理:

in vec3 position;

uniform mat4 modelView;
uniform mat4 projection;

void main() {
    gl_Position = projection * modelView * vec4(position, 1.0);
}

矩阵数据通过 uniform 传送:

// 初始化阶段,从着色器取得 uniform 变量的位置
int modelViewLoc = glGetUniformLocation(shaderProgram, "modelView");

// 绘制阶段,传送数据
glUniformMatrix4fv(modelViewLoc, 1, GL_FALSE, modelViewMatrix);

如果绘制很多同样的物体,我们就需要重复地调用 glUniformMatrix4fv,这样的调用也会成为瓶颈。解决方法就是实例化渲染(instanced rendering),也就是一次性提交多个矩阵:

in vec3 position;

uniform mat4 modelView[100]; // 一次性可以提交至多 100 个矩阵
uniform mat4 projection; // projection 一般是不用变的,可以不用实例化渲染

void main() {
    gl_Position = projection * modelView[gl_InstanceID] * vec4(position, 1.0);
}
// 一次性提交 100 个矩阵
glUniformMatrix4fv(modelViewLoc, 100, GL_FALSE, modelViewMatrices);

帧缓冲对象和渲染到纹理

有的时候,我们需要动态地生成一些纹理,然后再将这些纹理用于渲染。在 OpenGL 3.0 之前,我们需要在 OpenGL 把内容输出之后,从帧缓冲区中读取数据,然后再作为纹理上传给 GPU。这样做非常低效,因为我们需要在 CPU 和 GPU 之间来回传输数据。而帧缓冲对象(Frame Buffer Object, FBO)则可以让我们在 GPU 上直接生成纹理,“本地生产,本地消费”,这种方式称为渲染到纹理(Render To Texture)

// 初始化阶段
int fboId;
glGenFramebuffers(1, &fboId); // 在 GPU 那边创建一个 FBO,把句柄存储到 fboId 中
int textureId;
glGenTextures(1, &textureId); // 在 GPU 那边创建一个纹理,把句柄存储到 textureId 中
// 将纹理和 FBO 关联起来
glBindFramebuffer(GL_FRAMEBUFFER, fboId); // 选择要操作的 FBO
glBindTexture(GL_TEXTURE_2D, textureId); // 选择要操作的纹理
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0); // 生成纹理
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureId, 0); // 把纹理附加到 FBO 上
// ...

// 纹理生成阶段
glBindFramebuffer(GL_FRAMEBUFFER, fboId); // 选择要操作的 FBO
// 之后的所有操作都会被记录到这个 FBO 中
// ...
glBindFramebuffer(GL_FRAMEBUFFER, defaultFramebufferId); // 切换回默认的 FBO

// 绘制阶段
glBindTexture(GL_TEXTURE_2D, textureId); // 选择要操作的纹理
// 之后就能用这个纹理进行绘制了

Vulkan 的诞生

“老骥伏枥,志在千里,烈士暮年,壮心不已。”然而,不幸的是,尽管 OpenGL 在不断引入新的特性,但其发展仍然始终受到其早期设计的限制:

因此,新近的 API 基本都在围绕着解决这些问题来设计,设计思想可谓是殊途同归:

Vulkan 就是这样的一个 API,由 OpenGL 原 班 人 马 Khronos Group 开发。

结论

在本文中,我们讨论了 GPU 的特性,以及图形 API 为适应 GPU 特性的演化过程。这两篇文章的深度和广度都非常有限,但希望能够让读者对图形 API 的发展有一个大致的了解。

如果想要学习 Vulkan,可以查看:

当然,Vulkan API 因其学习曲线,并不适合所有人,尤其不适合初学者。如果想要学习 OpenGL,可以查看:

参考文献