OpenGL 编程/科学 OpenGL 教程 04
现在我们已经掌握了绘制二维绘图,是时候来解决三维绘图了。它与绘制二维绘图没有太大区别,只是需要添加第三维并选择合适的模型-视图-投影 (MVP) 矩阵来以清晰的方式呈现三维数据。
然而,一个显著的区别是我们现在有更多的数据要绘制,因为数据点的数量现在是平方。来自 第二个图形教程 的策略,将绘制的顶点数量与数据点数量分开,现在将真正发挥作用,所以我们也会在本教程中使用它。
我们还将看到,在绘制网格线时,顶点会重复使用多次。为了确保我们重复使用顶点,并解决其他一些问题,我们将使用索引缓冲区对象 (IBO)。
我们将使用 墨西哥帽 函数的 3D 版本。基本上,这只是一个变量的函数,但我们将使用到原点的距离作为该变量,来制作它的旋转对称版本
我们将在 N x N 点的网格中评估此函数。我们将使其易于在编译时更改点的确切数量
#define N 256
GLbyte graph[N][N];
for(int i = 0; i < N; i++) {
for(int j = 0; j < N; j++) {
float x = (i - N / 2) / (N / 2.0);
float y = (j - N / 2) / (N / 2.0);
float t = hypotf(x, y) * 4.0;
float z = (1 - t * t) * expf(t * t / -2.0);
graph[i][j] = roundf(z * 127 + 128);
}
}
hypot*() 函数并不为人所知,但它们是 C99 标准的一部分,非常方便。我们对函数进行了某种缩放,以确保帽子完美地适合 -1..1 范围内。我们现在可以告诉 OpenGL 使用此数据作为二维纹理
glActiveTexture(GL_TEXTURE0);
glGenTextures(1, &texture_id);
glBindTexture(GL_TEXTURE_2D, texture_id);
glTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE, N, N, 0, GL_LUMINANCE, GL_UNSIGNED_BYTE, graph);
练习
- 尝试不同的 N 值。您的显卡支持的最大大小是多少?纹理占用多少内存?
- 我们不需要一次性评估所有 N x N 点。使其一次只评估 N 个点,并将部分结果放入纹理中,使用 glTexSubImage2D()。
- 尝试使用图像而不是函数(例如,可以使用来自 现代 OpenGL 教程 06 的纹理)。
- 使其能够使用 F1 和 F2 键打开和关闭纹理插值和包装。
我们将使用的顶点着色器的工作原理与第二个图形教程中的着色器基本相同。但是,在那里我们有在二维屏幕上绘制二维函数的便利,所以我们不需要变换顶点坐标,我们只需要稍微移动一下纹理。在绘制 3D 函数时,我们确实必须以某种方式投影顶点,以便对所有三个维度进行解释。此外,我们现在可以沿两个维度移动纹理。因此,使用两个通用变换矩阵是有意义的;一个用于纹理坐标,一个用于顶点坐标。这就是我们的着色器的样子
attribute vec2 coord2d;
uniform mat4 texture_transform;
uniform mat4 vertex_transform;
uniform sampler2D mytexture;
varying vec4 graph_coord;
void main(void) {
graph_coord = texture_transform * vec4(coord2d, 0, 1);
graph_coord.z = texture2D(mytexture, graph_coord.xy / 2.0 + 0.5).r;
gl_Position = vertex_transform * vec4(coord2d, graph_coord.z, 1);
}
属性coord2d具有与属性相同的功能coord1d来自第二个图形教程。统一矩阵texture_transform接管了制服的角色offset_x和scale_x. 统一矩阵vertex_transform是新的,将用于更改我们对图形的视图。在主函数中,我们通过将texture_transform矩阵应用于我们提供给它的 2D 坐标来恢复图形坐标。一旦我们知道了这一点,我们就可以通过使用这些坐标进行纹理查找来恢复 z 坐标。我们将图形坐标保存在一个 varying vec4 中,以便片段着色器可以使用它们来为图形提供漂亮的颜色。该gl_Position变量的计算方式与在第二个教程中所做的相同,只是应用了新的变换矩阵。
我们将使用以下片段着色器
varying vec4 graph_coord;
void main(void) {
gl_FragColor = graph_coord / 2.0 + 0.5;
}
如果您已完成之前的教程,您已经了解了如何从偏移量和缩放变量创建变换矩阵。这次没有区别,只是我们有两个偏移量变量,分别用于 x 轴和 y 轴
glm::mat4 texture_transform = glm::translate(glm::scale(glm::mat4(1.0f), glm::vec3(scale, scale, 1)), glm::vec3(offset_x, offset_y, 0));
glUniformMatrix4fv(uniform_texture_transform, 1, GL_FALSE, glm::value_ptr(texture_transform));
此外,从 现代 OpenGL 教程 05 我们已经了解了如何创建模型、视图和投影矩阵。让我们将顶点保持在从 (-1, -1, -1) 到 (1, 1, 1) 的一个框中,因此我们的模型变换矩阵只是单位矩阵。然后我们可以将相机定位在例如 (0, -2, 2) 处,其中向量 (0, 0, 1) 是向上方向。所以我们有点在图形的前面和上面,向下看着它。最后,我们将使用与其他教程中使用的相同的透视投影。生成的 MVP 矩阵按如下方式计算
glm::mat4 model = glm::mat4(1.0f);
glm::mat4 view = glm::lookAt(glm::vec3(0.0, -2.0, 2.0), glm::vec3(0.0, 0.0, 0.0), glm::vec3(0.0, 0.0, 1.0));
glm::mat4 projection = glm::perspective(45.0f, 1.0f * 640 / 480, 0.1f, 10.0f);
glm::mat4 vertex_transform = projection * view * model;
glUniformMatrix4fv(uniform_vertex_transform, 1, GL_FALSE, glm::value_ptr(vertex_transform));
练习
- 使其能够使用向上和向下键更改offset_y,并使用 Page Up 和 Page Down 键更改缩放比例。
- 更改模型矩阵,使图形随着时间的推移绕 z 轴缓慢旋转。
- 使其能够通过按 F3 键切换旋转。
绘制三维函数的一种方法是绘制网格线。这正是 gnuplot 在使用splot命令时默认执行的操作,所以我们也会这样做。我们之前在二维绘图中使用了 101 个点,因此我们现在将使用 101 x 101 个点的网格,并将它们放入我们的 VBO 中
struct point {
GLfloat x;
GLfloat y;
};
point vertices[101][101];
for(int i = 0; i < 101; i++) {
for(int j = 0; j < 101; j++) {
vertices[i][j].x = (j - 50) / 50.0;
vertices[i][j].y = (i - 50) / 50.0;
}
}
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof vertices, vertices, GL_STATIC_DRAW);
顶点按正确的顺序绘制水平线(即具有恒定 y 坐标的线)。唯一的问题是我们不能只对glDrawArrays(GL_LINE_STRIP)进行一次调用,因为它不知道何时到达图形的右边缘并返回到左边缘。相反,它会创建锯齿线模式。最简单的解决方案是手动绘制 101 条线
glBindBuffer(GL_ARRAY_BUFFER);
glVertexAttribPointer(attribute_coord2d, 2, GL_FLOAT, GL_FALSE, 0, 0);
for(int i = 0; i < 101; i++)
glDrawArrays(GL_LINE_STRIP, 101 * i, 101);
这有效,尽管我们确实进行了 101 次 OpenGL 调用,这并不多,但人们更愿意避免这样做。我们还需要绘制垂直线,但顶点顺序不正确!不过,在这种情况下,我们可以通过使用步长和指针参数来作弊
for(int i = 0; i < 101; i++) {
glVertexAttribPointer(attribute_coord2d, 2, GL_FLOAT, GL_FALSE, 101 * sizeof(point), (void *)(i * sizeof(point)));
glDrawArrays(GL_LINE_STRIP, 0, 101);
}
练习
- 为什么我们必须在 glVertexAttribPointer() 中使用偏移指针?我们不能将其保留为 0 并使用glDrawArrays(GL_LINE_STRIP, i, 101)代替吗?
- 想办法创建一个具有重复顶点的顶点数组,以便您可以通过对 glDrawArrays(GL_LINE_STRIP) 进行一次调用来绘制所有水平线和垂直线。
- 如果您不受 OpenGL ES 的限制,请查看 glMultiDrawArrays() 命令。
- 假设你想要绘制三角形来填充整个 101 x 101 的正方形。你能否对顶点进行排序,以便你能够使用一次glDrawArrays(GL_TRIANGLE_STRIP)调用来绘制所有内容,而不会浪费顶点?使用多次glDrawArrays()?
如果我们想用三角形绘制图形,以获得填充的表面而不是网格,我们就不能再重复使用我们的 VBO 了,因为网格线的顶点顺序与三角形的完全不同。如果我们想同时绘制填充的表面 *和* 网格线,我们需要为所有顶点创建多个副本,仅仅因为排序问题,我们需要多个 glDrawArrays() 命令。
幸运的是,有一种方法可以将一组顶点与其绘制顺序分离。使用glDrawElements()函数,我们可以拥有第二个数组,其中包含指向顶点数组(或任何其他属性数组)的索引。不幸的是,仍然没有办法使用GL_LINE_STRIP进行绘制,因为索引数组也不能告诉 OpenGL 线段的起始位置和结束位置。但是我们可以使用 GL_LINES 进行绘制!现在,你可能会认为我们又有很多重复,因为我们必须从顶点索引 0 绘制到 1,从 1 绘制到 2,等等。但是,索引是较小的数字,通常只有 1 或 2 个字节,而属性通常要大得多。即使在我们简单的案例中,我们的 2D 顶点属性也有 8 个字节。因此,开销要小得多。优点是我们可以使用一次 glDrawElements() 调用来绘制水平和垂直网格线的所有线段,而不会绘制任何不必要的像素。当然,我们也可以通过使用索引缓冲区对象将索引存储在 GPU 的内存中。以下是
GLushort indices[2 * 100 * 101 * 2];
int i = 0;
// Horizontal grid lines
for(int y = 0; y < 101; y++) {
for(int x = 0; x < 100; x++) {
indices[i++] = y * 101 + x;
indices[i++] = y * 101 + x + 1;
}
}
// Vertical grid lines
for(int x = 0; x < 101; x++) {
for(int y = 0; y < 100; y++) {
indices[i++] = y * 101 + x;
indices[i++] = (y + 1) * 101 + x;
}
}
GLint ibo;
glGenBuffers(1, &ibo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof indices, indices, GL_STATIC_DRAW);
索引数量是每条线段两个,每条网格线有 100 个线段。然后,我们有两次 101 条网格线。以下是我们最终使用来自 VBO 的顶点和来自 IBO 的索引绘制网格的方式
glEnableVertexAttribArray(attribute_coord2d);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glVertexAttribPointer(attribute_coord2d, 2, GL_FLOAT, GL_FALSE, 0, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
glDrawElements(GL_LINES, 2 * 100 * 101 * 2, GL_UNSIGNED_SHORT, 0);
练习
- 找出如何只使用一次 glDrawElements() 调用绘制垂直网格线,而无需更改任何其他内容。
- 使用 glDrawElements() 绘制的顶点数是否有限制?
- 创建一个索引数组,使用 GL_TRIANGLES 绘制填充的表面。
- 你能将同一个 IBO 与另一个 VBO 重用吗?或者同一个 VBO 与另一个 IBO 重用吗?
- 假设你有两个属性数组,一个用于顶点,一个用于颜色。找出 glDrawElements() 在这种情况下是如何工作的。