Jawed studies computer science at the University of Illinois at Urbana-Champaign. He works part-time at the National Center for Supercomputing Applications, and can be contacted at [email protected].
OpenGL is known in the UNIX world as the 3D API behind high-powered scientific applications. It has recently gained attention in the PC sector, thanks to the computer-game industry, which has embraced OpenGL as an API standard for 3D game programming. Furthermore, 3D hardware acceleration for PCs has extended the range of applications for OpenGL even further.
The OpenGL API is intuitive, easier to use, in my opinion, than Microsoft's Direct3D API, and is portable among platforms. In this article, I'll present a model viewer for use with OpenGL on Windows 95/NT. First, however, I'll describe the important parts of a Quake2 model viewer -- an OpenGL-based system written in C/C++ -- that displays wire-frame and texture-mapped models (see Figure 1) from Quake2 and provides a basic interface to modify their appearance. In the process, I'll focus on file formats (MD2 files for models, and PCX files for textures), passing the data contained in the files to OpenGL for rendering, and interfacing Win32 with OpenGL using an API called "WGL." The archive Q2M-SRC.ZIP contains the Quake2 Model Viewer source code, while Q2M-BIN.ZIP is the Quake2 Model Viewer EXE file. Both are available electronically; see "Resource Center," page 3.
Reading the MD2 File Format
The only official source of information about Quake2's MD2 format is code by John Carmack of id Software; this code writes 3D polygon mesh data to an MD2 file (available at ftp://ftp.idsoftware.com/). Anyone who has looked at this source code will notice that some of the structs in Quake2 Model Viewer's md2.h (available electronically) are derived from it. Writing the MD2 reader basically involves converting John's code from reading MD2 files to writing them. Figure 2 illustrates the binary structure of an MD2 file.
To display the textured Quake2 models, four specific types of information are needed (see Figure 3):
- 3D vertex coordinates.
- A list of triangles consisting of those vertices.
- 2D texture vertex coordinates (one for each 3D vertex).
- The texture image.
All of the 3D vertices in the model are stored in one array. When the triangles (which are made up of those vertices) are defined, all that has to be stored for each vertex of a triangle is an index number to the big vertex array. The reason for this is simple: Since many of the vertices are shared between triangles, storing each vertex once saves memory. In addition, linear transformations can be performed on the entire array at once, thereby speeding rendering time. Since the texture image itself is not a part of the MD2 file, it can be read in from a conventional PCX file.
Before starting, you must know how much data to expect. The file's header section tells you the number of vertices, triangles, and texture coordinates contained in the file. Knowing when to stop, you can go into a loop and read the information in chunks. To store all the data, use the vertex structure in Listing One
Each triangle is defined by its corners, a, b, and c. These values are indices to an array of type make_vertex_list, which is a list of all 3D vertices in the entire model. The remaining six integers represent the 2D texture coordinates for every vertex. Listing Two is an example of a structure for holding this data. Using such a structure, the coordinates of the three vertices of the first triangle in the model can be referenced (see Listing Three).
In a Quake2 model, the only things that differ from one frame to the next are the 3D coordinates of the triangle vertices; the vertex indices and texture coordinates remain the same. From frame to frame, each triangle still consists of the same three vertices -- only the vertices undergo linear transformations. To hold each frame in an array, you create another array of type make_frame_list (Listing Four), each of which contains an array of vertex coordinates (Vertex 1, 2, and 3, respectively). There exists one copy of this array for each frame. Having filled all of the data structures, you can look up the coordinates of any polygon in any frame; see Listing Five (the coordinates of polygon P in frame F).
Texturing the Object
Quake2's model textures reside as separate PCX files, either in the pak0.pak file or quake2/baseq2 directory. Since OpenGL itself does not provide a way to read the binary PCX graphics file format, you can read the PCX file and pass its data to OpenGL.
Figure 4 describes the PCX format. The three basic sections in the file are the header, pixel data, and palette data. You can use two arrays of type unsigned char to store the last two sections. The header contains some basic information about the particular file, such as the PCX version, and the file dimensions. If the file is actually a PCX Version 5 file, the first two bytes in the file must be equal to 10 and 5, respectively. Having determined the image dimensions from the header section, you dynamically allocate an array of type unsigned char of size(width*height) for the pixel data and read it into the buffer byte-by-byte. Because a Version 5 PCX file can support exactly 256 colors, the size of the palette section is always 768 bytes (3*256, or RBG*256).
When the CImage::Read (char filename[]) function is finished, the m_pixel_buffer array is filled with all the pixels in the image, and m_palette_buffer contains consecutive RGB values for each of the colors.
How do you get the color of a specific pixel in the image? The pixel buffer simply contains index values of the palette buffer. Listing Six shows two methods. The R, G, and B components of the first pixel (pixel zero) in the image are Listing Six(a). However, because the palette array contains consecutive RBG values (RGBRGBRGBRGB...) for all the colors, the individual R, G, and B values at pixel position P are obtained by properly offsetting the array index; see Listing Six(b). Finally, to be able to reference color values at specific (X,Y) coordinates in the texture, P is substituted by X+Y*Width, where Width is the width of the texture; see Listing Six(c).
OpenGL
Once the necessary data is organized and stored in memory, you can start rendering using OpenGL. But first, some of OpenGL's texturing options must be set. In particular, you must specify how to treat textures when wrapped and indicate the "minification" and magnification filters (Listing Seven).
In addition, back-face culling and texturing have to be explicitly enabled. Since you won't be looking at the backsides of polygons, you only have to enable front-side filling of polygons. Lastly, you specify the texture function (Listing Eight).
OpenGL's glTexImage2D() is the function that actually textures the object. It expects to be passed, among other parameters, a pointer to an array containing successive RGBA values for each pixel in the texture (for example, RGBARGBARGBA...).
Thus, before calling glTexImage2D(), two changes must be made:
- 1. The pixel and palette data read from the PCX file must be copied into another array, of a format that glTexImage2D() can accept as a parameter.
- 2. Because OpenGL requires the dimensions of a texture to be powers of two, the texture has to be rescaled first using gluScaleImage().
Both of these steps are accomplished in CImage::Image2GLTexture(), which first creates a new array called unScaled, fills it with RGBA components, and rescales it to an appropriate size. The loop in Listing Nine fills a new array with RGBA components of each pixel in the texture, again offsetting the array indices as in the PCX code.
Now the texture contained within unScaled can be rescaled to have dimensions that are powers of two. To prevent the texture from losing much quality while keeping the performance at a reasonable level, a power of two that is closest to the original dimension will be used. For example, if the original width is greater than 256 pixels, the new dimension should be 512 pixels. If the original width is 128 or greater (but less than 256), the rescaled dimension should be 256. After a series of if statements have determined a good fit for the new dimensions, a call to gluScaleImage() rescales the texture (Listing Ten).
Finally, the glTexture array can be passed to OpenGL as follows: glTexImage2D(GL_TEXTURE_2D,0,4,scaledWidth, scaledHeight,0,GL_RGBA,GL_UNSIGNED_BYTE, glTexture);. Table 1 provides a quick explanation of the parameters.
Creating an OpenGL Rendering Context
WGL provides an interface between the Win32 API and OpenGL. It sets up a palette for your rendering window and handles such things as double buffering. To do this, you usually need to use four or five of the fewer than 20 WGL functions. I have written a basic C++ wrapper class for the functions that is easy to use. Most of the code in the COpenGLWindow class is taken from Silicon Graphics' OpenGL Developer Tools CD-ROM for Windows 95/NT, which interestingly has become a collector's item since SGI's "Fahrenheit" deal with Microsoft. (SGI is cooperating with Microsoft on the next generation of OpenGL. Since the agreement, SGI's, OpenGL drivers for Windows 95/NT have disappeared from the SGI web site, and the SGI OpenGL Developer CD-ROM for Windows 95/NT is hard to come by. However, there are several web sites mirroring its contents, including http://jawed.ncsa.uiuc.edu/.)
The dimensions of the rendering window are passed to the constructor, but its window handle must be passed to the OpenGLWindow::Create() class member function to actually create the rendering context.
WGL does not physically create a window for you; that is Win32's responsibility. WGL creates an OpenGL rendering context for a window that has already been created. If you want a window to create and destroy its OpenGL rendering context as the window is created and destroyed, simply catch the WM_CREATE and WM_DESTROY messages in the window's window procedure. Then call OpenGLWindow::Create() and OpenGLWindow::Destroy(), respectively, as has been done in inter.c's GraphicsProc function (available electronically). The only other time you really need to use WGL is for a system palette change. Windows will indicate that such a change has been made by sending a WM_PALETTECHANGED message to every window, and then OpenGLWindow::RedoPalette() will take care of the change.
Drawing the Entire Model
Inter.cpp's redraw() function (available electronically) redraws the entire model in its current state by specifying all of the triangle vertex coordinates and texture mapping coordinates between glBegin(GL_TRIANGLES) and glEnd(). This requires three calls to glTexCoord2f() (two parameters) and glVertex3f() (three parameters) for every triangle. One thing to note about the glTextCoord2f() function is that OpenGL expects texture-mapping coordinates to be relative, not absolute. To obtain these coordinate values, divide the original texture mapping coordinates from the model by their maximum range in the texture. In other words, divide the S component by the texture map's width and divide T by the texture map's height. These values will fall between 0 and 1 and remain unchanged when the texture is resized. For instance, (0.5, 0.5) will always point to the center pixel of the texture, no matter whether the texture dimensions are 173×233 or 256×256. Of course, doing a floating-point divide three times per loop is inefficient. By storing these values ahead of time the loop's efficiency could be improved greatly.
Between frame redraws the rendering window's window procedure keeps track of mouse movements and mouse button activity by listening to WM_MOUSEMOVE, and WM_*BUTTON(UP/DOWN) messages. The movement increments are then temporarily stored in two arrays -- one for translational movements, and another one for rotations. At the beginning of each frame redraw the linear transformations are carried out using glTranslate() and glRotate().
Conclusion
Although OpenGL is straightforward to use, simply knowing the API is not sufficient. Since OpenGL does not provide functions to read 3D model and texture files of your preferred format, a basic understanding of 3D concepts and some amount of manual data manipulation is also required. Combining Win32 with OpenGL makes it possible to develop applications with user-friendly interfaces and impressive 3D graphics.
Keep in mind that one of OpenGL's bonuses is portability. Porting your Win32 OpenGL applications to X under UNIX should not be much more difficult than cutting and pasting some of the graphics code. Of course, creating another interface from scratch will be necessary.
DDJ
Listing One
typedef struct{ float x, y, z; /* coordinates */ } make_vertex_list;
Listing Two
typedef struct{ int a, b, c; /* array indices */ int a_s, a_t, /* (s, t) texture coordinates */ b_s, b_t, c_s, c_t; } make_index_list;
Listing Three
<b>(a)</b>(vertex_list[index_list[0].a].x, vertex_list[index_list[0].a].y, vertex_list[index_list[0].a].z) </p> <b>(b)</b> (vertex_list[index_list[0].b].x, vertex_list[index_list[0].b].y, vertex_list[index_list[0].b].z) </p> <b>(c)</b> (vertex_list[index_list[0].c].x, vertex_list[index_list[0].c].y, vertex_list[index_list[0].c].z)
Listing Four
typedef struct{ make_vertex_list *vertex; } make_frame_list;
Listing Five:
<b>(a)</b>frame_list[F].vertex[index_list[P].a].x frame_list[F].vertex[index_list[P].a].y frame_list[F].vertex[index_list[P].a].z </p> <b>(b)</b> frame_list[F].vertex[index_list[P].b].x frame_list[F].vertex[index_list[P].b].y frame_list[F].vertex[index_list[P].b].z </p> <b>(c)</b> frame_list[F].vertex[index_list[P].c].x frame_list[F].vertex[index_list[P].c].y frame_list[F].vertex[index_list[P].c].z
Listing Six
<b>(a)</b>R: m_palette_buffer [ m_pixel_buffer[0]] G: m_palette_buffer [ m_pixel_buffer[1]] B: m_palette_buffer [ m_pixel_buffer[2]] </p> <b>(b)</b> R: m_palette_buffer [3 * m_pixel_buffer[P]+0] G: m_palette_buffer [3 * m_pixel_buffer[P]+1] B: m_palette_buffer [3 * m_pixel_buffer[P]+2] </p> <b>(c)</b> R: m_palette_buffer [3 * m_pixel_buffer[X + Y*Width]+0] G: m_palette_buffer [3 * m_pixel_buffer[X + Y*Width]+1] B: m_palette_buffer [3 * m_pixel_buffer[X + Y*Width]+2]
Listing Seven
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
Listing Eight
glEnable(GL_CULL_FACE);glEnable(GL_TEXTURE_2D); glPolygonMode (GL_FRONT, GL_FILL); glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_DECAL);
Listing Nine
Glubyte *unScaled = new GLubyte [m_iWidth * m_iHeight * 4];for (j = 0; j < m_iHeight; j++) { for (i = 0; i < m_iWidth; i++) { unScaled[4*(j * m_iWidth + i)+0] = (GLubyte) m_palette_buffer[3*m_pixel_buffer[j*m_iWidth+i]+0]; unScaled[4*(j * m_iWidth + i)+1] = (GLubyte) m_palette_buffer[3*m_pixel_buffer[j*m_iWidth+i]+1]; unScaled[4*(j * m_iWidth + i)+2] = (GLubyte) m_palette_buffer[3*m_pixel_buffer[j*m_iWidth+i]+2]; unScaled[4*(j * m_iWidth + i)+3] = (GLubyte) 255; } }
Listing Ten
/* allocate memory for the new rescaled texture */glTexture = new GLubyte [m_iscaledWidth * m_iscaledHeight * 4]; </p> /* use the OpenGL function to rescale */ gluScaleImage (GL_RGBA, m_iWidth, m_iHeight, GL_UNSIGNED_BYTE, unScaled, m_iscaledWidth, m_iscaledHeight, GL_UNSIGNED_BYTE, glTexture); </p> /* reclaim memory of the unscaled texture */ delete [] unScaled;
Copyright © 1998, Dr. Dobb's Journal