现代OpenGL教程01 —— 入门指南

2015年08月12日 14:22 0 点赞 0 评论 更新于 2025-11-21 16:38

译序

早年学习OpenGL时,使用的还是1.x版本,采用的是glVertexglNormal等固定管线API。后来因工作需要接触DirectX 9,着色器(shader)只是可选项,常与固定管线混合使用。如今从事手机游戏开发,转向了OpenGL ES,发现OpenGL的世界已截然不同。从OpenGL ES 2.0版本开始,不再支持固定管线,仅支持可编程管线。

国内许多OpenGL资料和教程质量参差不齐,旧式接口随处可见。在知乎上看到这一系列教程,觉得很不错,便打算边学习边翻译。毕竟手游市场的机遇与竞争压力同步猛增,多了解OpenGL ES肯定有益无害。在浮躁功利的环境下,更需保持一颗宁静致远的心来提升自身技术功底。前路漫漫,与诸君共勉。

欢迎阅读现代OpenGL教程系列的第一篇。所有代码均为开源,你可以在GitHub上下载:https://github.com/tomdalling/opengl-series

通过这篇教程,你将学会如何在Windows系统下使用Visual Studio 2013,或在Mac系统下使用Xcode搭建OpenGL 3.2工程。该应用包含一个顶点着色器(vertex shader)、一个片段着色器(fragment shader),并使用顶点数组对象(VAO)和顶点缓冲对象(VBO)来绘制三角形。此工程借助GLEW访问OpenGL API,利用GLFW处理窗口创建和输入,还使用GLM进行矩阵和矢量相关的数学运算。

虽然这听起来有些枯燥,但搭建这样的工程确实颇具挑战,尤其是对于初学者而言。不过,一旦解决了这个问题,我们就能开启有趣的探索之旅。

获取代码

所有示例代码的zip压缩包可从以下链接获取:https://github.com/tomdalling/opengl-series/archive/master.zip

本系列文章所使用的代码均存放在:https://github.com/tomdalling/opengl-series。你可以在该页面下载zip文件,若你熟悉git,也可复制该仓库。

本文代码可在source/01_project_skeleton目录中找到。使用OS X系统的用户,可打开根目录里的opengl-series.xcodeproj,并选择本文对应的工程;使用Windows系统的用户,可在Visual Studio 2013中打开opengl-series.sln,然后选择相应工程。

工程中已包含所有依赖项,因此你无需额外安装或配置其他内容。若在编译或运行过程中遇到任何问题,请联系我。

关于兼容性的提醒

本文使用OpenGL 3.2,我会尽力保持以下兼容性:

  • 向后兼容OpenGL 2.1
  • 向前兼容OpenGL 3.X和4.X
  • 兼容Android和iOS的OpenGL ES 2.0

由于OpenGL和GLSL存在多个不同版本,本文代码可能无法实现100%的上述兼容。我期望达到99%的兼容性,且不同版本之间只需进行轻微修改。

若想了解OpenGL和GLSL不同版本间的区别,这里有一份详细的兼容列表可供参考。

不同系统下的安装步骤

Visual Studio下安装

代码在Windows 7 32位系统、Visual Studio Express 2013(免费版)中创建并测试。你应该能够打开解决方案并成功编译所有工程。若遇到问题,请联系我,或发送补丁给我,我会更新工程。

Xcode下安装

Xcode工程在OSX 10.10系统、Xcode 6.1中创建并测试。打开Xcode工程后,应能成功编译所有目标。若无法成功编译,请联系我。

Linux下安装

Linux部分基于SpartanJ。我在Ubuntu 12.04中进行了简单测试,步骤如下:

  1. 安装GLM、GLFW和GLEW:sudo aptitude install libglm-dev libglew-dev libglfw-dev
  2. 进入工程目录:cd platforms/linux/01_project_skeleton
  3. 运行makefile:make
  4. 运行可执行文件:bin/01_project_skeleton-debug

GLEW、GLFW和GLM介绍

现在你已拥有工程,接下来介绍工程中使用的开源库以及使用它们的原因。

GLEW(The OpenGL Extension Wrangler)

GLEW用于访问OpenGL 3.2 API函数。遗憾的是,若不使用旧版本的OpenGL,无法简单地通过#include来访问OpenGL接口。在现代OpenGL中,API函数是在运行时(run time)确定的,而非编译期(compile time)。GLEW可在运行时加载OpenGL API。

GLFW

GLFW允许我们跨平台创建窗口,并接收鼠标和键盘消息。由于OpenGL本身不处理窗口创建和输入,因此需要借助其他工具。我选择GLFW是因为它体积小巧且易于理解。

GLM(OpenGL Mathematics)

GLM是一个数学库,用于处理矢量、矩阵等几乎所有数学运算。旧版本的OpenGL提供了类似glRotateglTranslateglScale等函数,但在现代OpenGL中,这些函数已不再存在,我们需要自行处理所有数学运算。GLM将在后续教程中为矢量和矩阵运算提供很大帮助。

在本系列的所有教程中,我们还编写了一个小型库tdogl,用于重用C++代码。本文将使用tdogl::Shadertdogl::Program来加载、编译和链接着色器。

什么是着色器(Shaders)

着色器在现代OpenGL中是一个至关重要的概念。若不理解着色器,应用程序代码将毫无意义。

着色器是一段使用OpenGL着色语言(GLSL)编写的小程序,运行在GPU上而非CPU。GLSL语言与C或C++相似,但实际上是一种不同的语言。使用着色器的过程与编写普通程序类似:编写代码、编译,最后链接在一起生成最终程序。

“着色器”并非一个恰当的名称,因为它的功能不仅限于着色。只需记住它是一种运行在显卡上、使用不同语言编写的小程序即可。

在旧版本的OpenGL中,着色器是可选的;而在现代OpenGL中,若要在屏幕上显示物体,着色器是必不可少的。

若想深入了解着色器和图形渲染管线,推荐阅读Durian Software的相关文章《The Graphics Pipeline chapter》。

顶点着色器(Vertex Shaders)

顶点着色器主要用于将点(x,y,z坐标)转换为不同的点。顶点是几何形状中的一个点,单个点称为“vertex”,多个点称为“vertices”(发音为ver - tuh - seez)。在本教程中,三角形由三个顶点组成。

顶点着色器的GLSL代码如下:

#version 150
in vec3 vert;
void main() {
// 不改变顶点
gl_Position = vec4(vert, 1);
}
  • 第一行#version 150:告知OpenGL该着色器使用GLSL 1.50版本。
  • 第二行in vec3 vert;:指定着色器需要一个顶点作为输入,并将其存储在变量vert中。
  • 第三行void main():定义着色器的入口函数。与C语言不同,GLSL中的main函数无需参数,也无需返回值。
  • 第四行gl_Position = vec4(vert, 1);:将输入的顶点直接输出。gl_Position是OpenGL定义的全局变量,用于存储顶点着色器的输出。所有顶点着色器都必须对gl_Position进行赋值。由于gl_Position是4D坐标(vec4),而vert是3D坐标(vec3),因此需要将vert转换为4D坐标vec4(vert, 1),其中第二个参数1是赋值给第四维坐标。在后续教程中,我们将深入学习4D坐标的相关知识,目前可将其视为3D坐标。

在本文中,顶点着色器未进行任何处理,后续我们将对其进行修改,以处理动画、摄像机等。

片段着色器(Fragment Shaders)

片段着色器的主要功能是计算每个需要绘制的像素点的颜色。

“片段”(fragment)基本上可视为一个像素,因此可以将片段着色器看作像素着色器。在本文中,每个片段对应一个像素,但实际情况并非总是如此。你可以更改某些OpenGL设置,以获得比像素更小的片段,后续文章将对此进行详细介绍。

本文使用的片段着色器代码如下:

#version 150
out vec4 finalColor;
void main() {
// 将每个绘制的像素设置为白色
finalColor = vec4(1.0, 1.0, 1.0, 1.0);
}
  • 第一行#version 150:同样告知OpenGL该着色器使用GLSL 1.50版本。
  • 第二行finalColor = vec4(1.0, 1.0, 1.0, 1.0);:将输出变量设置为白色。vec4(1.0, 1.0, 1.0, 1.0)创建了一个RGBA颜色,其中红、绿、蓝和alpha值均设为最大值,即白色。

现在,我们可以使用着色器在OpenGL中绘制出纯白色。在后续文章中,我们将添加不同的颜色和贴图,贴图即3D模型上的图像。

编译和链接着色器

在C++中,需要对.cpp文件进行编译,然后链接在一起组成最终程序。OpenGL的着色器也是如此。

本文使用了两个可复用的类tdogl::Shadertdogl::Program来处理着色器的编译和链接。这两个类的代码量不多,且有详细注释,建议你阅读源码以了解OpenGL的工作原理。

什么是VBO和VAO

当着色器在GPU上运行,而其他代码在CPU上运行时,需要一种方式将数据从CPU传输到GPU。在本文中,我们传输了一个三角形的三个顶点数据;在大型工程中,3D模型可能包含成千上万个顶点、颜色、贴图坐标等数据。

这就是我们需要顶点缓冲对象(Vertex Buffer Objects,VBOs)和顶点数组对象(Vertex Array Objects,VAOs)的原因。VBO和VAO用于将C++程序的数据传递给着色器进行渲染。

在旧版本的OpenGL中,通过glVertexglTexCoordglNormal函数将每帧数据发送给GPU。而在现代OpenGL中,所有数据必须在渲染之前通过VBO发送到显卡。当需要渲染某些数据时,通过设置VAO来描述应从哪些VBO中获取数据,并将其传递给着色器变量。

顶点缓冲对象(Vertex Buffer Objects,VBOs)

第一步,我们需要将三角形的三个顶点从内存上传到显存中,这正是VBO的作用。VBO实际上是显存中的“缓冲区(buffers)”,是一串包含各种二进制数据的字节区域。你可以上传3D坐标、颜色,甚至是音乐和诗歌等数据。VBO不关心数据的具体内容,它只是对内存进行复制。

顶点数组对象(Vertex Array Objects,VAOs)

第二步,我们使用VBO的数据在着色器中渲染三角形。需要注意的是,VBO只是一块数据,它并不清楚数据的类型。而VAO的作用是告诉OpenGL缓冲区中数据的类型。

VAO将VBO和着色器变量连接起来,它描述了VBO中包含的数据类型,以及应将数据传递给哪个着色器变量。在OpenGL众多不准确的技术名词中,“顶点数组对象”是最糟糕的一个,因为它并未准确解释VAO的功能。

回顾本文前面的顶点着色器代码,我们只有一个输入变量vert。在本文中,我们使用VAO来告知OpenGL:“这里的VBO包含3D顶点数据,我希望在顶点着色器中,将三个顶点数据传递给vert变量。”

在后续文章中,我们将使用VAO来描述更复杂的情况,例如:“这里的VBO包含3D顶点、颜色和贴图坐标数据,我希望在着色器中,将顶点数据传递给vert变量,将颜色数据传递给vertColor变量,将贴图坐标数据传递给vertTexCoord变量。”

给使用旧OpenGL版本用户的提醒

如果你在旧版本的OpenGL中使用了VBO但未使用VAO,可能会对VAO的描述存在异议。你可能会认为“顶点属性”可以使用glVertexAttribPointer将VBO和着色器连接起来,而不是使用VAO。这取决于你是否认为顶点属性应该是VAO“内置(inside)”的(我持此观点),或者它们是否是VAO外置的一个全局状态。

在3.2内核和我使用的ATI驱动中,VAO不是可选项。没有VAO的封装,glEnableVertexAttribArrayglVertexAttribPointerglDrawArrays都会导致GL_INVALID_OPERATION错误。这就是我认为顶点属性应该内置于VAO,而非全局状态的原因。OpenGL 3.2内核手册也指出VAO是必须的,但我仅听说ATI驱动会抛出此类错误。以下是引用自OpenGL 3.2内核手册的描述:

所有与顶点处理有关的数据定义都应该封装在VAO里。一般VAO边界包含所有更改顶点数组状态的命令,如VertexAttribPointerEnableVertexAttribArray;所有使用顶点数组进行绘制的命令,如DrawArraysDrawElements;所有对顶点数组状态进行查询的命令(见第6章)。

无论如何,我理解有人认为顶点属性应该放在VAO外部的原因。glVertexAttribPointer的出现早于VAO,在这段时间里,顶点属性一直被视为全局状态。可以看出,VAO是一种改变全局状态的有效方法。我更倾向于这样理解:如果你没有创建VAO,OpenGL会提供一个默认的全局VAO。因此,当你使用glVertexAttribPointer时,实际上仍然是在VAO内修改顶点属性,只是从默认的VAO切换到了你自己创建的VAO。

这里有更多相关讨论:[http://www.opengl.org/discussion_boards/showthread.php/17207 - Questions - on - VAOs](http://www.opengl.org/discussion_boards/showthread.php/17207 - Questions - on - VAOs)

代码解释

终于,理论部分介绍完毕,让我们开始编码。OpenGL对于初学者来说并不友好,但如果你理解了前面介绍的概念(着色器、VBO、VAO),那么编码过程将不会有太大问题。

初始化GLFW

打开main.cpp,从main()函数开始。首先,我们初始化GLFW:

glfwSetErrorCallback(OnError);
if (!glfwInit())
throw std::runtime_error("glfwInit failed");

glfwSetErrorCallback(OnError)这一行告诉GLFW,当错误发生时调用OnError函数。OnError函数会抛出一个包含错误信息的异常,以便我们定位问题。

创建窗口

glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 2);
glfwWindowHint(GLFW_RESIZABLE, GL_FALSE);
GLFWwindow* gWindow = glfwCreateWindow((int)SCREEN_SIZE.x, (int)SCREEN_SIZE.y, "OpenGL Tutorial", NULL, NULL);
if (!gWindow)
throw std::runtime_error("glfwCreateWindow failed. Can your hardware handle OpenGL 3.2?");

该窗口包含一个向前兼容的OpenGL 3.2内核上下文。如果glfwCreateWindow失败,你可能需要降低OpenGL版本。

设置当前OpenGL上下文

glfwMakeContextCurrent(gWindow);

无论调用哪个OpenGL函数,都会影响“当前上下文”。在本教程中,我们只使用一个上下文,设置完成后无需再关注。理论上,我们可以创建多个窗口,每个窗口都可以有自己的上下文。

初始化GLEW

glewExperimental = GL_TRUE; // 避免GLEW在OSX上崩溃
if (glewInit() != GLEW_OK)
throw std::runtime_error("glewInit failed");

GLEW与OpenGL内核存在一些小问题,设置glewExperimental可以解决,但希望未来不再出现此类问题。

确认OpenGL 3.2版本是否可用

if (!GLEW_VERSION_3_2)
throw std::runtime_error("OpenGL 3.2 API is not available.");

编译和链接着色器

LoadShaders函数中,我们使用本教程提供的tdogl::Shadertdogl::Program两个类来编译和链接顶点着色器和片段着色器:

std::vector<tdogl::Shader> shaders;
shaders.push_back(tdogl::Shader::shaderFromFile(ResourcePath("vertex - shader.txt"), GL_VERTEX_SHADER));
shaders.push_back(tdogl::Shader::shaderFromFile(ResourcePath("fragment - shader.txt"), GL_FRAGMENT_SHADER));
tdogl::Program* gProgram = new tdogl::Program(shaders);

创建和设置VBO和VAO

创建和绑定VAO

GLuint gVAO;
glGenVertexArrays(1, &gVAO);
glBindVertexArray(gVAO);

创建和绑定VBO

GLuint gVBO;
glGenBuffers(1, &gVBO);
glBindBuffer(GL_ARRAY_BUFFER, gVBO);

上传数据到VBO

GLfloat vertexData[] = {
//  X     Y     Z
0.0f, 0.8f, 0.0f,
-0.8f, -0.8f, 0.0f,
0.8f, -0.8f, 0.0f
};
glBufferData(GL_ARRAY_BUFFER, sizeof(vertexData), vertexData, GL_STATIC_DRAW);

启用着色器变量

glEnableVertexAttribArray(gProgram->attrib("vert"));

设置VAO

glVertexAttribPointer(gProgram->attrib("vert"), 3, GL_FLOAT, GL_FALSE, 0, NULL);
  • 第一个参数gProgram->attrib("vert"):指定需要上传数据的着色器变量,在本例中为vert
  • 第二个参数3:表示每个顶点需要三个数字。
  • 第三个参数GL_FLOAT:说明三个数字的类型为GLfloat。这一点非常重要,因为GLdouble类型的数据大小与GLfloat不同。
  • 第四个参数GL_FALSE:表示不需要对浮点数进行“归一化”。若进行归一化,值将被限制在0到1之间。由于我们不需要对顶点进行限制,因此该参数为false
  • 第五个参数0:当顶点之间存在间隔时可使用该参数,设置为0表示数据之间没有间隔。
  • 第六个参数NULL:若数据不是从缓冲区头部开始的,可以设置该参数指定起始位置。设置为NULL表示数据从VBO的第一个字节开始。

解绑定VBO和VAO

glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);

绘制三角形

清空屏幕

glClearColor(0, 0, 0, 1); // 黑色
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

使用着色器和VAO

glUseProgram(gProgram->object());
glBindVertexArray(gVAO);

绘制三角形

glDrawArrays(GL_TRIANGLES, 0, 3);

调用glDrawArrays函数表示我们要绘制三角形,从第0个顶点开始,共有3个顶点被发送到着色器。OpenGL会在当前VAO范围内确定从哪里获取顶点。

顶点将从VBO中取出并发送到顶点着色器,然后三角形内的每个像素将被发送到片段着色器,片段着色器将每个像素设置为白色。

解绑定着色器和VAO

glBindVertexArray(0);
glUseProgram(0);

切换帧缓冲

glfwSwapBuffers(gWindow);

在帧缓冲交换之前,我们绘制到一个不可见的离屏(off - screen)帧缓冲区。调用glfwSwapBuffers后,离屏缓冲将变为屏幕缓冲,我们就能在窗口中看到绘制的内容了。

进一步阅读

在后续文章中,我们将为三角形添加贴图。之后,你将学习一些矩阵变换知识,从而使用顶点着色器实现3D立方体的旋转。再往后,我们将开始创建3D场景并渲染多个物体。

更多现代OpenGL资料

由于篇幅限制,本教程不得不跳过许多内容。以下是一些优秀的现代OpenGL资料,可满足你的求知欲:

作者信息

洞悉

洞悉

共发布了 3994 篇文章