OpenGL 编程/科学 OpenGL 教程 01
虽然 OpenGL 以其在游戏中的应用而闻名,但它还有许多其他应用。其中之一是科学数据的可视化。从技术上讲,绘制数据集和绘制游戏图形之间没有太大区别,但重点不同。科学家通常需要正投影视图,而不是数据的透视视图。科学数据通常使用主色调和少量的平滑着色来呈现,而不是镜面高光、反射和阴影。这听起来可能只使用简单的 OpenGL 功能,但作为回报,科学家希望数据以高精度渲染,没有任何伪像,并且没有任意裁剪几何体或灯光。此外,原始数据可能需要在渲染之前进行大量转换,而这些转换并不总是可以作为矩阵乘法实现。在可编程着色器出现之前,科学可视化在显卡上要困难得多。
在接下来的教程中,我们将假设您已经阅读了教程 06。
一个基本的科学可视化任务是绘制函数或一些数据点的图形。我们将从绘制以下函数开始
看起来像波浪,在原点附近振幅为 1,并且随着你远离原点而衰减。如果你安装了gnuplot,那么你可以使用以下命令轻松绘制此函数
f(x) = sin(10 * x) / (1 + x * x)
plot f(x)
就像 gnuplot 做的那样,我们首先需要在一个数量点上评估函数,然后我们可以绘制经过这些点的线。我们将在范围中 2000 个点上评估函数。
由于我们的函数不随时间变化,因此如果我们只将计算的点发送到 GPU 一次,那就太好了。为此,我们将把这些点存储在一个顶点缓冲对象中。这使我们能够将数据的拥有权交给 GPU,然后 GPU 可以将副本存储在它自己的内存中,例如。
我们还想放大和缩小,以及四处移动,以更详细地探索该函数。为此,我们将有一个缩放和偏移变量。顶点着色器将使用这些变量将我们的“原始”数据点转换为屏幕坐标。
只是在一些点上绘制一条线并不是绘制函数的唯一方法。也可以根据原始的 x 和 y 坐标对线应用颜色。或者可以绘制不同的形状。例如,比较以下 gnuplot 命令的结果
plot f(x) with lines
plot f(x) with dots
plot f(x) with points
前两种形式可以通过使用 GL_LINES 和 GL_POINTS 绘制顶点来轻松实现。最后一种形式在每个点绘制 + 号。碰巧的是,OpenGL 有一个名为“点精灵”的函数,它基本上允许你在以顶点为中心的正方形上绘制纹理的内容,这使得复制 gnuplot 的使用点绘制风格。
顶点缓冲对象 (VBO) 只是保存顶点数据的缓冲区对象。它与使用顶点数组非常相似,但也有一些例外。首先,OpenGL 将为我们分配和释放存储空间。其次,我们必须明确地告诉 OpenGL 我们何时想要访问 VBO。这样做的想法是,当我们不想自己访问 VBO 时,GPU 可以独占访问 VBO 的内容,甚至可以将内容存储在它自己的内存中,因此它不必每次需要顶点时都从缓慢的主内存中获取数据。
首先,我们创建自己的 2000 个 2D 数据点的数组并填充它
struct point {
GLfloat x;
GLfloat y;
};
point graph[2000];
for(int i = 0; i < 2000; i++) {
float x = (i - 1000.0) / 100.0;
graph[i].x = x;
graph[i].y = sin(x * 10.0) / (1.0 + x * x);
}
然后我们创建一个新的缓冲区对象
GLuint vbo;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
的glGenBuffers()和glBindBuffer()函数的工作方式与 OpenGL 中其他对象的工作方式相同。此外,与我们使用 glTexImage*() 分配和上传纹理的方式类似,我们使用以下命令上传图形
glBufferData(GL_ARRAY_BUFFER, sizeof graph, graph, GL_STATIC_DRAW);
GL_STATIC_DRAW表示我们不会经常写入此缓冲区,并且 GPU 应该在它自己的内存中保留一个副本。始终可以将新值写入 VBO。如果数据每帧或更频繁地更改一次,则应使用GL_DYNAMIC_DRAW或GL_STREAM_DRAW。当在现有 VBO 中覆盖数据时,应使用 glBufferSubData() 函数,它是 glTexSubImage*() 的模拟。
假设我们已经设置了其他所有内容,并且我们已准备好绘制经过这些点的线。那么我们只需要做以下事情
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glEnableVertexAttribArray(attribute_coord2d);
glVertexAttribPointer(
attribute_coord2d, // attribute
2, // number of elements per vertex, here (x,y)
GL_FLOAT, // the type of each element
GL_FALSE, // take our values as-is
0, // no space between values
0 // use the vertex buffer object
);
glDrawArrays(GL_LINE_STRIP, 0, 2000);
glDisableVertexAttribArray(attribute_coord2d);
glBindBuffer(GL_ARRAY_BUFFER, 0);
第一行告诉我们使用包含图形的 VBO。然后我们告诉 OpenGL 我们正在提供给它一个顶点数组。的最后一个参数glVertexAttribPointer()以前是指向顶点数组的指针。但是,我们将其设置为 0 以告诉 OpenGL 它应该使用当前绑定缓冲区对象中的数据。因此,我们不需要在此处将缓冲区对象映射到指针!这样做会破坏 VBO 的所有性能优势。然后,我们使用通常的 glDraw 命令进行绘制。GL_LINE_STRIP 模式告诉 OpenGL 在连续顶点之间绘制线段,使得有一条连续线穿过所有顶点。之后,我们可以告诉 OpenGL 我们不再想要使用顶点数组和我们的缓冲区对象。
练习(在您实现下面提到的着色器后完成)
- 尝试使用 GL_LINES、GL_LINE_LOOP、GL_POINTS 或 GL_TRIANGLE_STRIP 代替进行绘制。
- 尝试通过更改的参数绘制仅可见点的子集glDrawArrays().
- 尝试通过修改的参数绘制仅偶数点glVertexAttribPointer().
- 尝试使用 glBufferSubData() 更改图形的一部分。
有一种替代方法可以访问 VBO 中的数据。我们可以要求 OpenGL 将 VBO 映射到主内存,而不是告诉 OpenGL 将数据从我们自己的内存复制到显卡。根据显卡的不同,这可能会避免需要进行复制,因此可能会更快。另一方面,映射本身可能很昂贵,或者它可能不会真正映射任何东西,而只是执行复制。也就是说,这是它的工作原理
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, 2000 * sizeof(point), NULL, GL_STATIC_DRAW);
point *graph = (point *)glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY);
for(int i = 0; i < 2000; i++) {
float x = (i - 1000.0) / 100.0;
graph[i].x = x;
graph[i].y = sin(x * 10.0) / (1.0 + x * x);
}
glUnmapBuffer(GL_ARRAY_BUFFER);
绑定到我们的 VBO 后,我们像以前一样调用 glBufferData(),只是我们传递了一个 NULL 指针。这将告诉 OpenGL 为我们的 2000 个数据点分配内存。然后我们使用 glMapBuffer() 函数将缓冲区“映射”到主内存。我们使用GL_WRITE_ONLY来表示我们只写入内存。这告诉 GPU 它永远不必将 GPU 内存复制回主内存,这在某些体系结构上可能很昂贵。在我们拥有指向缓冲区的指针后,我们可以像往常一样写入它。最后一条命令告诉 OpenGL 我们已完成它。这会将缓冲区从主内存中取消映射(或者它可能会导致我们的数组被上传到 GPU,例如)。从那时起,我们不能再使用图形指针了。
严格来说,glMapBuffer() 不是核心 OpenGL ES 2.0 语言的一部分,因此您不应该依赖它始终可用。
- 尝试找出在您的系统上哪种更改 VBO 内容的方法更快:glBufferData()、glBufferSubData() 或 glMapBuffer()。
如前所述,我们的着色器将非常简单。让我们从顶点着色器开始
attribute vec2 coord2d;
varying vec4 f_color;
uniform float offset_x;
uniform float scale_x;
void main(void) {
gl_Position = vec4((coord2d.x + offset_x) * scale_x, coord2d.y, 0, 1);
f_color = vec4(coord2d.xy / 2.0 + 0.5, 1, 1);
}
如您所见,我们对坐标进行的变换非常少。请记住,默认情况下,OpenGL 坐标 (1,1) 对应于窗口的右上角,而 (-1,-1) 对应于左下角。我们的 x 值从 -10 到 10,而 y 值从 -1 到 1。如果我们不应用任何变换,我们只会看到 部分的图形。因此,我们引入了两个统一变量,允许我们放大和缩小以及四处移动:offset_x 和 scale_x。我们将 offset_x 添加到 x 坐标,然后将结果乘以 scale_x。
- 如果您先进行乘法再加偏移,会发生什么?哪种更有效?您需要在 C++ 代码中更改什么才能获得与之前相同的行为?
- 原则上,MVP 矩阵也能让我们移动和缩放。但是,尝试更改顶点着色器以绘制对数图。
我们还有一个变型 f_color,我们可以用它根据原始坐标为每个点分配颜色。虽然这里只是为了展示,但它可以用来向绘图添加更多信息。片段着色器非常简单
varying vec4 f_color;
void main(void) {
gl_FragColor = f_color;
}
现在我们有了统一变量 offset_x 和 scale_x,我们想要一种控制它们的方法。在更复杂的程序中,可以使用 Qt 或 Gtk 等工具包,并使用滚动条或鼠标控制进行缩放和移动。在 GLUT 中,我们可以非常轻松地实现一个键盘处理程序,让我们与程序进行交互。假设我们有以下全局变量
GLint uniform_offset_x;
GLint uniform_scale_x;
float offset_x = 0.0;
float scale_x = 1.0;
到目前为止,您应该知道如何在着色器中获取对统一变量的引用,以及如何在display()函数中设置它们的值。因此,我们只会看一下我们的键盘处理函数
void special(int key, int x, int y)
{
switch(key) {
case GLUT_KEY_LEFT:
offset_x -= 0.1;
break;
case GLUT_KEY_RIGHT:
offset_x += 0.1;
break;
case GLUT_KEY_UP:
scale_x *= 1.5;
break;
case GLUT_KEY_DOWN:
scale_x /= 1.5;
break;
case GLUT_KEY_HOME:
offset_x = 0.0;
scale_x = 1.0;
break;
}
glutPostRedisplay();
}
它被称为special()因为 GLUT 区分了“普通”字母数字键和“特殊”键,如功能键、光标键等。为了告诉 GLUT 在按下特殊键时调用我们的函数,我们在main():
if (init_resources()) {
glutDisplayFunc(display);
glutSpecialFunc(special);
glutMainLoop();
}
- 中使用以下代码
- 使用光标键进行实验。尝试长时间按住按钮。
- 您认为 x 和 y 参数是什么?
使用 F1 和 F2 键,使您可以切换在绘制 GL_LINE_STRIP 和 GL_POINTS 之间。
点精灵在绘制测量数据而不是数学函数时,科学家通常在数据点处绘制小的符号,例如十字和正方形。我们可以通过使用 GL_LINES 绘制这些符号来做到这一点,但是我们也可以将这些符号作为纹理,并在数据点为中心的方块上绘制这些纹理。您应该知道如何从 教程 06 中做到这一点。但是,我们可以使用点精灵功能让 OpenGL 为我们处理此操作,而不是自己绘制四边形或两个三角形,这将让我们在没有任何更改的情况下重新使用顶点缓冲区。
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glEnable(GL_POINT_SPRITE);
glEnable(GL_VERTEX_PROGRAM_POINT_SIZE);
您应该知道如何从上述教程中加载和启用纹理。您应该制作一个几乎透明的纹理,上面绘制了一个小的不透明符号,例如 +。为了正确绘制透明纹理并启用点精灵,我们调用以下函数
最后两条命令启用点精灵功能以及从顶点着色器中控制点大小的能力。对于 OpenGL ES 2.0,这些命令可能不需要,因为此功能始终启用。但是,您的显卡可能需要它才能正常运行(尽管有些显卡在顶点着色器点大小控制方面存在问题)。
glBindTexture(GL_TEXTURE_2D, texture_id);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
与您在大多数游戏中使用的情况相反,我们想要禁用纹理的插值,否则我们的符号看起来会模糊和不清晰,这在绘图中是不希望的。(您可以将此与字体的“提示”进行比较。)
glUniform1f(uniform_point_size, res_texture.width);
glDrawArrays(GL_POINTS, 0, 2000);
要使用点精灵在 VBO 中绘制点,我们调用
attribute vec2 coord2d;
varying vec4 f_color;
uniform float offset_x;
uniform float scale_x;
uniform float point_size;
void main(void) {
gl_Position = vec4((coord2d.x + offset_x) * scale_x, coord2d.y, 0, 1);
f_color = vec4(coord2d.xy / 2.0 + 0.5, 1, 1);
gl_PointSize = point_size;
}
第一行将所需的点大小(在本例中等于纹理的宽度)传递到顶点着色器中的统一变量。我们应该更改顶点着色器为
#version 120
uniform sampler2D mytexture;
varying vec4 f_color;
void main(void) {
gl_FragColor = texture2D(mytexture, gl_PointCoord) * f_color;
}
请注意,我们只是使用 GL_POINTS 进行绘制。如果我们像这样运行程序,您将看不到您的点精灵,而只是一些彩色方块!剩下的就是更改我们的片段着色器以实际绘制纹理,而不是纯色这看起来与普通的纹理着色器没什么不同。但是,我们现在在片段着色器中使用 gl_PointCoord 变量。它将从方块左上角的 (0,0) 运行到右下角的 (1,1),这正是我们获得正确纹理坐标所需的东西。此功能仅在 GLSL 版本 1.20 及更高版本中可用,因此我们应该在着色器源代码的顶部放置#version 120
- 尝试将纹理过滤器更改为 GL_LINEAR。研究点精灵是如何绘制的。
- 尝试在 C++ 程序中更改点大小。尝试非常小和非常大的尺寸。
- 研究如何使用 C++ 程序中的 glPointSize() 更改点大小,而不是使用顶点着色器(但这与 OpenGL ES 2.0 不兼容)。
- 尝试通过更改片段着色器将点精灵旋转 45 度。
- 尝试绘制圆形点精灵。
- 通过按下 F1、F2 和 F3,使您可以切换在绘制 GL_LINE_STRIP、普通 GL_POINTS 和点精灵之间。