OpenGL Programming/Scientific OpenGL Tutorial 01
Introduction
editAlthough OpenGL is widely known for its use in games, it has also many other applications. One of these is the visualization of scientific data. Technically, there is not a great difference between drawing datasets and drawing game graphics, but the emphasis is different. Instead of a perspective view of our data, the scientist usually wants an orthographic view. Instead of specular highlights, reflections and shadow, scientific data is usually presented with primary colors and just a bit of smooth shading. It may sound like only simple OpenGL features are used, but in return a scientist wants the data rendered with a high accuracy, without any artifacts, and without arbitrary clipping of geometry or lighting. Also, raw data might need a lot of transformation before it can be rendered, and these transformations cannot always be implemented as matrix multiplications. Before the advent of the programmable shaders, scientific visualisation was lot harder to do on graphics cards.
In the following tutorial, we will assume you have already read up to tutorial 06.
Plotting a function in 2D
editA basic scientific visualisation task is to make a graph of a function or some data points. We will start by plotting the following function:
Which looks like waves which have an amplitude of 1 near the origin and decay as you go away from the origin. If you have gnuplot installed, then you can easily plot this function with these commands:
f(x) = sin(10 * x) / (1 + x * x)
plot f(x)
Just like gnuplot does, we first need to evaluate the function in a number of points, then we can draw lines through those points. We will evaluate the function at 2000 points in the range .
Since our function does not change over time, it would be nice if we could send the points that we calculated to the GPU only once. To do that, we will store the points in a Vertex Buffer Object. This allows us to hand over ownership of the data to the GPU, which can then store a copy in its own memory for example.
We also want to zoom in and out, and move around, to explore the function in greater detail. To do that, we will have a scale and offset variable. The vertex shader will use these to transform our "raw" data points to screen coordinates.
Just drawing a line through some points is not the only way to plot a function. It is also possible to apply color to the line, depending on the original x and y coordinates. Or one can draw different shapes. Compare for example the results of the following gnuplot commands:
plot f(x) with lines
plot f(x) with dots
plot f(x) with points
The first two forms are easily implemented by drawing the vertices using GL_LINES and GL_POINTS. The last form draws + signs at each point. It so happens that OpenGL has a function called "point sprites", which basically allows you to draw the contents of a texture on a square centered around the vertices, which makes it very easy to copy gnuplot's with points drawing style.
Vertex buffer objects
editVertex buffer objects (VBOs) are just buffer objects that hold vertex data. It is very similar to using vertex arrays, with some exceptions. First, OpenGL will allocate and deallocate the storage space for us. Second, we must explicitly tell OpenGL when we want access to the VBO. The idea is that when we don't want to access the VBO ourself, the GPU can have exclusive access to the contents of the VBO, and can even store the contents in its own memory, so it doesn't need to fetch the data from the slow main memory every time it needs the vertices.
First, we create our own array of 2000 2D data points and fill it in:
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);
}
Then we create a new buffer object:
GLuint vbo;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
The glGenBuffers() and glBindBuffer() functions work just like those for other objects in OpenGL. And, also similar to how we allocate and upload a texture with glTexImage*(), we upload our graph with the following command:
glBufferData(GL_ARRAY_BUFFER, sizeof graph, graph, GL_STATIC_DRAW);
GL_STATIC_DRAW indicates that we will not write to this buffer often, and that the GPU should keep a copy of it in its own memory. It is always possible to write new values to the VBO. If the data changes once per frame or more often, you should use GL_DYNAMIC_DRAW or GL_STREAM_DRAW. When overwriting data in an existing VBO, you should use the glBufferSubData() function, which is the analogue of glTexSubImage*().
Suppose we have already set up everything else, and we are ready to draw a line through these points. Then we just need to do the following:
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);
The first line tells us to use the VBO with our graph in it. Then we tell OpenGL that we are giving it an array of vertices. The last parameter of glVertexAttribPointer() used to be a pointer to the vertex array. However, we set this to 0 to tell OpenGL that it should use data from the currently bound buffer object instead. So we do not need to map our buffer object to a pointer here! Doing so would destroy all the performance benefits of VBOs. Then, we draw using the usual glDraw commands. The GL_LINE_STRIP mode tells OpenGL to draw line segments between consecutive vertices, such that there is a continous line that goes through all the vertices. Afterwards, we can tell OpenGL we no longer want to use vertex arrays and our buffer object.
Exercises (do them after you implemented the shaders mentioned below):
- Try drawing using GL_LINES, GL_LINE_LOOP, GL_POINTS or GL_TRIANGLE_STRIP instead.
- Try to draw only the subset of the points that are visible by changing the parameters of glDrawArrays().
- Try to draw only the even numbered points by modifying the parameters of glVertexAttribPointer().
- Try changing part of the graph using glBufferSubData().
Mapping a buffer to memory
editThere is an alternative way to access the data that is in a VBO. Instead of telling OpenGL to copy data from our own memory to the graphics card, we can ask OpenGL to map the VBO into main memory. Depending on the graphics card, this might avoid the need to perform a copy, so it could be faster. On the other hand, the mapping itself could be expensive, or it might not really map anything but perform a copy anyway. That said, this is how it works:
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);
After binding to our VBO, we call glBufferData() like before, except that we pass a NULL pointer. This will tell OpenGL to allocate the memory for our 2000 data points. Then we "map" the buffer into main memory using the glMapBuffer() function. We indicate that we only write to the memory with GL_WRITE_ONLY. This tells the GPU it never has to copy GPU memory back to main memory, which might be expensive on some architectures. After we have a pointer to the buffer, we can write to it as usual. The final command tells OpenGL we are done with it. This unmaps the buffer from main memory (or it might cause our array to be uploaded to the GPU, for example). From then on, we cannot not use the graph pointer anymore.
Strictly, glMapBuffer() is not part of the core OpenGL ES 2.0 language, so you should not rely on it being always available.
- Try to find out which method of changing the contents of a VBO is faster on your system: glBufferData(), glBufferSubData() or glMapBuffer().
Shaders
editAs mentioned before, our shaders will be very simple. Let's begin with the vertex shader:
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);
}
As you can see, we perform very little transformations on our coordinates. Remember that by default, the OpenGL coordinate (1,1) correspond to the upper right corner of the window, and (-1,-1) the lower left corner. Our x values go from -10 to 10, and our y values from -1 to 1. If we would not apply any transformation, we would only see the part of our graph. So, we introduce two uniform variables that allow us to zoom in and out and move around: offset_x and scale_x. We add offset_x to the x coordinate, and the multiply the result by scale_x.
- What happens if you multiply first and then add the offset? Which is more efficient? What would you have to change in the C++ code to get the same behavior as before?
- In principle a MVP matrix would also allow us to move and zoom. However, try to change the vertex shader so it draws a logarithmic plot instead.
We also have a varying f_color which we can use to assign a color to each point, depending on the original coordinates. Although it is just for show here, it can be used to add more information to the plot. The fragment shader is very simple:
varying vec4 f_color;
void main(void) {
gl_FragColor = f_color;
}
Keyboard interaction
editNow that we have the uniforms offset_x and scale_x, we want some way of controlling them. In a more sophisticated program, one would use a toolkit like Qt or Gtk, and use scrollbars or mouse controls to zoom and move around. In GLUT, we can very easily implement a keyboard handler that lets us interact with the program. Assume we have the following global variables:
GLint uniform_offset_x;
GLint uniform_scale_x;
float offset_x = 0.0;
float scale_x = 1.0;
By now, you should know how to get references to the uniform variables in the shaders, and how to set their values in the display() function. So, we will just look at our keyboard handling function instead:
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();
}
It is called special() because GLUT makes a distinction between the "normal" alphanumeric keys and "special" keys like function keys, cursor keys, and so on. To tell GLUT to call our function whenever a special key is being pressed, we have the following in main():
if (init_resources()) {
glutDisplayFunc(display);
glutSpecialFunc(special);
glutMainLoop();
}
- Experiment with the cursor keys. Try holding buttons for a long time.
- What do you think the x and y parameters are?
- Make it so you can switch between drawing GL_LINE_STRIP and GL_POINTS with the F1 and F2 keys.
Point sprites
editWhen plotting measurement data instead of mathematical functions, scientists usually draw little symbols at the data points, like crosses and squares. We can do this by drawing those symbols with GL_LINES for example, but we could also have those symbols as textures, and draw those on little squares centered on the data points. You should know how to do this from tutorial 06. However, instead of having to draw a quad or two triangles ourself, we can let OpenGL handle this for us using the point sprite functionality, which will let us reuse our vertex buffer without any changes.
You should know how to load and enable textures from the aforementioned tutorial. You should make a mostly transparent texture with a small opaque symbol drawn on it, like a +. To properly draw transparent textures, and to enable point sprites, we call these functions:
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glEnable(GL_POINT_SPRITE);
glEnable(GL_VERTEX_PROGRAM_POINT_SIZE);
The last two commands enables point sprite functionality and the ability to control the point size from within the vertex shader. For OpenGL ES 2.0, these commands should not be necessary, as this functionality is always enabled. However, it may be required for your graphics card to function correctly (although some do have issues with vertex shader point size control).
Contrary to what you would use in most games, we want to disable interpolation for the texture, otherwise our symbols will look fuzzy and unsharp, which is not desirable in a plot. (You can compare this to the "hinting" of fonts.)
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);
To draw the points in our VBO with point sprites, we call:
glUniform1f(uniform_point_size, res_texture.width);
glDrawArrays(GL_POINTS, 0, 2000);
The first line passes the desired point size (equal to the width of the texture in our case) to the vertex shader in a uniform. We should change the vertex shader to:
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;
}
Notice that we draw just by using GL_POINTS. If we run the program like this, you will not see your point sprites, but just some colored squares! What remains is to change our fragment shader to actually draw the texture instead of a solid color:
#version 120
uniform sampler2D mytexture;
varying vec4 f_color;
void main(void) {
gl_FragColor = texture2D(mytexture, gl_PointCoord) * f_color;
}
This does not look too different from a regular texture shader. However, we are now using the gl_PointCoord variable in the fragment shader. It will run from (0,0) in the top left of the square to (1,1) in the bottom right, exactly what we need to get the right texture coordinates. This functionality is only available in GLSL version 1.20 and later, therefore we should put #version 120 at the top of the shader source code.
- Try changing the texture filters to GL_LINEAR. Research how exactly point sprites are drawn.
- Try to change the point size in the C++ program. Try really small and really big sizes.
- Research how you can change the point size using glPointSize() in the C++ program instead of using the vertex shader (this is not OpenGL ES 2.0 compatible though).
- Try to rotate the point sprites 45 degrees by changing the fragment shader.
- Try to draw circular point sprites.
- Make it so you can switch between drawing GL_LINE_STRIP, normal GL_POINTS and point sprites by pressing F1, F2 and F3.