OpenGL编程/科学OpenGL教程03
前两个教程重点关注绘制曲线,但是如果您使用任何专业的绘图工具,您会注意到图形带有标题、轴、刻度线、网格线、图例以及(最重要的是,但经常被遗忘的)轴标签。绘制图形的大部分工作实际上是在绘制图形周围的所有内容。
您将遇到的最大问题是,一方面您拥有图形坐标,我们可以相对轻松地对其进行转换以将图形正确地放置在屏幕上,另一方面您拥有像素坐标,您希望将其用于图形周围的所有内容。在图形的边界附近,您希望绘制刻度线和坐标的地方,这两个坐标空间将相遇。
在本教程中,我们将了解如何将绘图绘制在一个比窗口稍小的矩形中,而不会使曲线泄漏出去。我们还将了解如何在图形的左侧和底部正确绘制刻度线,以便它们与曲线匹配。结果将开始类似于gnuplot的输出。
这次我们将使用非常简单但通用的着色器。顶点着色器将仅将变换矩阵应用于二维顶点
attribute vec2 coord2d;
uniform mat4 transform;
void main(void) {
gl_Position = transform * vec4(coord2d.xy, 0, 1);
}
我们将使用固定的纯色,直接通过uniform传递到片段着色器
uniform vec4 color;
void main(void) {
gl_FragColor = color;
}
让我们使用顶点缓冲对象存储我们的数据,并使用我们可以使用键盘更改的变量offset_x和scale_x,绘制与第一个图形教程中完全相同的图形。为了使其看起来更专业,我们可以在白色背景上以红色绘制它。
请记住,我们现在有一个顶点着色器,它希望一个变换矩阵而不是使用offset_x和scale_x本身。使用GLM,我们可以自己从这些变量创建矩阵,只需在单位矩阵上应用缩放和平移操作即可。然后我们可以将其发送到顶点着色器
GLint uniform_transform = glGetUniformLocation(program, "transform");
glm::mat4 transform = glm::translate(glm::scale(glm::mat4(1.0f), glm::vec3(scale_x, 1, 1)), glm::vec3(offset_x, 0, 0));
glUniformMatrix4fv(uniform_transform, 1, GL_FALSE, glm::value_ptr(transform));
如果我们现在绘制图形,它应该看起来与第一个教程相同(除了它是白色背景上的红色)。图形仍然覆盖整个屏幕。相反,我们希望稍微缩小图形,并使图形周围有一些空间用于刻度线和其他内容。让我们为图形的左侧和底部保留一些刻度线空间,并在所有内容周围留出边距。我们希望空间独立于窗口的大小,或者换句话说,它应该是固定数量的像素。让我们现在定义它
const int margin = 20;
const int ticksize = 10;
当然,现在我们有了变换矩阵,我们可以轻松地缩放和平移图形。但是无论我们如何更改矩阵,这都不会阻止它绘制在整个屏幕上。我们可以手动确定哪些顶点位于我们绘图指定区域内,并仅绘制这些顶点,但这将是一项非常繁重的工作。我们也可以先绘制绘图,然后通过绘制填充的矩形来清除其周围的区域。但是所有这些都很愚蠢,我们只想告诉GPU为我们裁剪绘图。
我们可以使用多种OpenGL方法来裁剪绘图。我们从glViewport()函数开始。这定义了窗口内要绘制的区域(以像素为单位)。我们可以使用glutGet()调用来找出窗口的实际大小,并像这样计算精确的区域
int window_width = glutGet(GLUT_WINDOW_WIDTH);
int window_height = glutGet(GLUT_WINDOW_HEIGHT);
glViewport(
margin + ticksize,
margin + ticksize,
window_width - margin * 2 - ticksize,
window_height - margin * 2 - ticksize
);
传递给glViewport()的前两个参数是从窗口左下角开始的x和y偏移量(以像素为单位)。后两个参数是视口的宽度和高度(以像素为单位)。尝试在display()函数的顶部添加此内容。您应该会看到现在绘图周围确实有一个清晰的边距。如果您增加边距或刻度线大小常量,或者尝试调整窗口大小,您会注意到视口不仅裁剪,而且还重新缩放绘图,以便之前适合整个窗口的所有内容现在都将完全适合视口区域。对于我们的目的,这很好,因为我们不必想出自己的变换来补偿绘图周围的边距。
此时,您可能会认为glViewport()确实裁剪了指定区域之外的所有像素。但是,这并不完全正确。发生的事情是几何图形被裁剪,以便所有将要绘制的顶点都位于视口区域内。不能保证片段会被裁剪,尽管某些显卡(例如nVidia)也可能自动执行此操作。您可以尝试使用glLineWidth()函数使线条非常粗,具体取决于您的显卡,您可以看到线条的中心在视口区域的边缘停止,但由于其厚度,像素可能会泄漏到视口区域之外。(如果您绘制下一部分中提到的框,可能会帮助您了解视口在哪里结束。)为了确保所有片段都被裁剪,您必须在设置视口后立即设置剪切区域并启用剪切测试
glScissor(
margin + ticksize,
margin + ticksize,
window_width - margin * 2 - ticksize,
window_height - margin * 2 - ticksize
);
glEnable(GL_SCISSOR_TEST);
练习
- 除了前者执行的额外坐标转换之外,glViewport()与glScissor()之间是否有任何优势?
- 尝试将对glClear()的调用移动到glViewport()和glScissor()之间。它有区别吗?如果在两者之后立即调用它会发生什么?
- 尝试绘制一些具有非常大点大小的GL_POINTS,一些正好在视口区域内,一些正好在视口区域外。
- 尝试再次绘制这些GL_POINTS,正好在窗口内和窗口外,而无需调用glViewport()。您能想到一种“修复”此行为的方法吗?
下一步是在绘图周围绘制一个带有刻度线的框。这次我们不希望发生任何裁剪,因此我们应该重置视口并禁用剪切以再次覆盖整个窗口
glViewport(0, 0, window_width, window_height);
glDisable(GL_SCISSOR_TEST);
现在的问题是我们失去了之前的自动坐标转换,因此我们不能再绘制角点为(-1, -1)和(1, 1)的框。不幸的是,没有简单的函数可以获得与glViewport()应用相同的变换,因此我们将编写我们自己的函数
glm::mat4 viewport_transform(float x, float y, float width, float height) {
// Calculate how to translate the x and y coordinates:
float offset_x = (2.0 * x + (width - window_width)) / window_width;
float offset_y = (2.0 * y + (height - window_height)) / window_height;
// Calculate how to rescale the x and y coordinates:
float scale_x = width / window_width;
float scale_y = height / window_height;
return glm::scale(glm::translate(glm::mat4(1), glm::vec3(offset_x, offset_y, 0)), glm::vec3(scale_x, scale_y, 1));
}
要理解此函数,只需想象您必须将窗口的中心移动到新视口的中心,并且您必须从窗口的宽度缩放到视口的宽度。我们现在可以使用与传递给glViewport()相同的参数调用此函数,并将结果传递给顶点着色器
transform = viewport_transform(
margin + ticksize,
margin + ticksize,
window_width - margin * 2 - ticksize,
window_height - margin * 2 - ticksize,
);
glUniformMatrix4fv(uniform_transform, 1, GL_FALSE, glm::value_ptr(transform));
然后我们用黑色绘制我们的框
GLuint box_vbo;
glGenBuffers(1, &box_vbo);
glBindBuffer(GL_ARRAY_BUFFER, box_vbo);
static const point box[4] = {{-1, -1}, {1, -1}, {1, 1}, {-1, 1}};
glBufferData(GL_ARRAY_BUFFER, sizeof box, box, GL_STATIC_DRAW);
GLfloat black[4] = {0, 0, 0, 1};
glUniform4fv(uniform_color, 1, black);
glVertexAttribPointer(attribute_coord2d, 2, GL_FLOAT, GL_FALSE, 0, 0);
glDrawArrays(GL_LINE_LOOP, 0, 4);
练习
- 我们可以在图形之前绘制框吗?或者可能在使用相同的glViewport()后立即绘制它?
现在我们已经绘制了曲线并在其周围绘制了框,是时候绘制刻度线了。这些小线通常放置在整数值或非常圆的细分处,并且可以更轻松地估计函数在特定点的值。您还可以将其视为一把尺子,主刻度表示厘米,次刻度表示毫米。
我们将从框左侧的刻度线开始,也称为y轴。由于我们的绘图具有-1..1的固定y范围,因此计算正确的坐标将非常容易。我们将尝试绘制从-1到1的21个刻度线,间距为0.1。
此时,如果我们继续使用绘制盒子时使用的相同变换矩阵,会更容易一些。这样, 恰好对应盒子的左边缘,而 和 分别对应盒子的底部和顶部。但我们如何从那里开始绘制精确为刻度线大小 像素 长度的线条呢?我们需要在图形坐标和像素坐标之间进行转换。最重要的是,我们需要知道一个像素在图形单位中有多大。请记住,我们用来绘制盒子的坐标范围从 -1 到 1(因此宽度为 2 个单位),但我们将视口设置为window_width - border * 2 - ticksize宽。我们可以对高度使用相同的推理。因此,我们的像素缩放因子将为
float pixel_x = 2.0 / (window_width - border * 2 - ticksize);
float pixel_y = 2.0 / (window_height - border * 2 - ticksize);
现在我们知道了这一点,我们可以计算出需要绘制 21 个刻度标记的 42 个顶点的坐标,并将这些坐标放入 VBO 中。
GLuint ticks_vbo;
glGenBuffers(1, &ticks_vbo);
glBindBuffer(GL_ARRAY_BUFFER, ticks_vbo);
point ticks[42];
for(int i = 0; i <= 20; i++) {
float y = -1 + i * 0.1;
ticks[i * 2].x = -1;
ticks[i * 2].y = y;
ticks[i * 2 + 1].x = -1 - ticksize * pixel_x;
ticks[i * 2 + 1].y = y;
}
glBufferData(GL_ARRAY_BUFFER, sizeof ticks, ticks, GL_STREAM_DRAW);
glVertexAttribPointer(attribute_coord2d, 2, GL_FLOAT, GL_FALSE, 0, 0);
glDrawArrays(GL_LINES, 0, 42);
请注意此处使用了 GL_STREAM_DRAW。尽管在本教程中 y 刻度始终相同,但在真实的绘图程序中它们将是可变的。我们稍后也将重用此 VBO 用于 x 刻度。GL_STREAM_DRAW 指示我们只会使用这些顶点绘制一次。
我们可以通过将每个第二个 x 坐标替换为以下内容,进一步区分主要刻度标记(位于单位值处)和次要刻度标记(每 0.1 个单位):
float tickscale = (i % 10) ? 0.5 : 1;
ticks[i * 2 + 1].x = -1 - ticksize * tickscale * pixel_x;
练习
- 尝试将刻度标记放在另一个边框上,或放在图形内部而不是外部。
- 不要绘制刻度标记,而是在浅灰色中绘制水平网格线。你能想到一些使线条出现在曲线下方的几种方法吗?
- 主要刻度现在每 1 个单位绘制一次。将其更改为每 2.54 个单位绘制一次,次要刻度每 0.254 个单位绘制一次。
y 刻度标记很容易,因为我们从未在 y 轴上缩放或平移图形。我们确切地知道从哪里开始和结束。但是对于 x 轴,我们有两个困难。首先,当我们平移图形时,我们的刻度标记应该与图形一起移动。但是当我们将图形向左移动时,刻度标记应该在盒子的左边缘消失,新的刻度标记应该出现在右边缘。其次,如果我们更改图形的比例,则刻度标记之间的间距应该相应调整。但是如果我们大幅缩小,则我们不希望在底部有数千个刻度标记。相反,我们希望每次超过 20 个刻度标记可见时,它们都会被精简。类似地,如果我们大幅放大,则刻度标记的密度应每次少于 2 个刻度标记可见时增加 10 倍。
刻度标记所需的间距可以相对容易地找到。基本上,我们从scale_x变量知道图形的比例。我们希望用它来缩放刻度标记之间的间距,但每次scale_x超过 10 的幂时,将其“重置”回 1。我们可以通过取scale_x的以 10 为底的对数,将其向下舍入到整数,然后将 10 乘以该整数的幂来获得对数舍入的缩放因子(0.1、1、10、100 等)。在图形单位中,次要刻度标记所需的间距为
float tickspacing = 0.1 * powf(10, -floor(log10(scale_x)));
为了找出应该在哪里绘制最左边和最右边的刻度标记,我们将首先确定图形可见部分的最左边和最右边的图形坐标是什么。我们知道 x 坐标在我们在其上绘制盒子的坐标系中为 -1 和 1,因此我们必须应用我们用来绘制图形的变换矩阵的逆。由于我们只对 x 坐标感兴趣,并且变换相当简单,因此我们可以手动执行此操作,而不是使用 GLM。
float left = -1.0 / scale_x - offset_x;
float right = 1.0 / scale_x - offset_x;
但是,不能保证这些坐标与刻度标记一致。我们确实知道原点至少有一个刻度标记,并且我们知道它们之间的间距。让我们对刻度标记进行编号,从原点的 0 开始。然后我们可以确定两个最接近但仍在左右边缘之间的刻度标记的编号。
int left_i = ceil(left / tickspacing);
int right_i = floor(right / tickspacing);
然后我们知道最左边刻度标记的坐标(以图形坐标表示)仅仅是left_i * tickspacing。然后,左边界与最左边刻度标记之间的差异(以图形单位表示)如下所示。
float rem = left_i * tickspacing - left;
现在我们可以计算我们将要绘制的坐标系中最左边刻度标记的坐标了。
float firsttick = -1.0 + rem * scale_x;
我们还可以轻松计算绘图坐标中刻度标记之间的距离是多少,并且我们只需查看left_i和right_i变量即可知道要绘制多少个刻度标记。如果我们做对了所有事情,那绝不应该超过 21 个刻度标记,但是始终最好严格施加限制,因为在对非常大或非常小的数字进行计算时(例如当您非常放大或缩小时)可能会发生奇怪的事情。由于我们已经对刻度标记进行了编号,因此我们还可以对 y 刻度标记使用相同的技巧来区分主要刻度标记和次要刻度标记。现在我们准备绘制 x 刻度标记了。
int nticks = right_i - left_i + 1;
if(nticks > 21)
nticks = 21;
for(int i = 0; i < nticks; i++) {
float x = firsttick + i * tickspacing * scale_x;
float tickscale = ((i + left_i) % 10) ? 0.5 : 1;
ticks[i * 2].x = x;
ticks[i * 2].y = -1;
ticks[i * 2 + 1].x = x;
ticks[i * 2 + 1].y = -1 - ticksize * tickscale * pixel_y;
}
glBufferData(GL_ARRAY_BUFFER, nticks * sizeof *ticks, ticks, GL_STREAM_DRAW);
glVertexAttribPointer(attribute_coord2d, 2, GL_FLOAT, GL_FALSE, 0, 0);
glDrawArrays(GL_LINES, 0, nticks * 2);
练习
- 使每四个刻度标记成为一个主要刻度标记。使刻度间距每次跨越 4 的幂而不是 10 的幂时重置。
- 使用left和right变量计算变换矩阵的逆。transform矩阵。