Sign In/My Account | View Cart  
advertisement


Listen Print Discuss

Building a 3D Engine in Perl
by Geoff Broadwell | Pages: 1, 2, 3

Something to See

It's time to draw something in that window. To do so, I need to do three things:

  1. Choose a projection, so that OpenGL knows how I want to look at the scene.
  2. Set the view, so OpenGL knows from which direction to view the scene (the viewpoint) and in which direction I wish to look.
  3. Define an object in the scene, placed where the viewpoint can see it.

To start, I need another config setting, so I'll add another line to the $conf hash in init_conf:

        fovy   => 90,

Next, for my three new functions, I add three new calls at the top of draw_frame:

sub draw_frame
{
    set_projection_3d();
    set_view_3d();
    draw_view();

Choose a Projection

set_projection_3d is as follows:

sub set_projection_3d
{
    my ($fovy, $w, $h) = @$conf{qw( fovy width height )};
    my $aspect = $w / $h;

    glMatrixMode(GL_PROJECTION);
    glLoadIdentity;
    gluPerspective($fovy, $aspect, 1, 1000);

    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity;
}

This is the first place you can see an indication of the hard part of 3D graphics simmering below the surface -- math, and lots of it. 3D-rendering code often includes a fairly hefty load of linear algebra (matrix math, for those blocking out their high school and college years) and trigonometry. Thankfully, OpenGL does a lot of that math under the covers. I've also defined a fairly simple projection and view, so this hides a lot of the complexity for now (aside from some of the OpenGL function names).

The first section of the routine defines the viewing projection. In the simplest case, that means choosing whether to use an orthogonal projection or a perspective projection. Orthogonal projections have no foreshortening. They commonly appear in architectural and engineering drawings, because parts that are the same size also appear the same size, no matter where they are in the scene.

Perspective projections are what we see in the real world with our own eyes or with a camera; distant objects appear smaller than near objects. It's also what you learn in a perspective drawing art class, in which the first assignment is commonly train tracks going off to the horizon. Tracks farther from the viewer appear closer together as does the spacing between the ties. To replicate the real world, I've chosen a perspective projection.

In OpenGL, you not only have to decide between an orthogonal or perspective projection, you have to define its basic dimensions. In other words, how much can you see? For a perspective projection, you define the vertical field of view (FOV), the aspect ratio of the view, and the distance to the nearest and farthest things visible.

The vertical FOV ($fovy in the code) defines the angle from the viewpoint to the lowest and highest visible parts of the scene. If you imagine drawing what someone would see if she were standing with her eyes at the viewpoint, this represents her vertical peripheral vision. If you imagine a camera instead, this depends on the focal length of the lens. A telephoto lens has a very small FOV because the angle from the camera to the top and bottom visible objects is very small. Conversely, a wide-angle lens has a large FOV, and the FOV for a fisheye lens is even larger, approaching 180 degrees.

The aspect ratio comes directly from the dimensions of the drawing area (width/height). This allows OpenGL to compensate for the stretching effect of a non-square window. In this case, the drawing area is square, so the aspect ratio is 1.

After calculating the window's aspect ratio, I tell OpenGL that I want to modify the projection and to start from a blank slate, using glMatrixMode(GL_PROJECTION) and glLoadIdentity. I then call gluPerspective to define the desired perspective. You probably noticed that gluPerspective begins with glu instead of gl, like all of the other calls we've seen. This is because I'm using one of the GLU (OpenGL Utility) routines to cover up some complexity in the equivalent raw OpenGL sequence.

Finally, I switch back to model/view mode, and once again start with a blank slate, using glMatrixMode(GL_MODELVIEW) and glLoadIdentity. You may wonder why I don't include this in the next routine instead of doing it here. I like to make sure routines that change a commonly used OpenGL state, simply as a side effect of their main purpose, return that state to the way they found it, especially if there is no net performance effect to doing so. In this case, I switch temporarily to projection mode and then switch back to the default model/view mode.

Set the View

The next step is to move the viewpoint to somewhere we can see the scene:

sub set_view_3d
{
    # Move the viewpoint so we can see the origin
    glTranslate(0, -2, -10);
}

I'm going to skip the detailed explanation for now, but in short the glTranslate call leaves the viewpoint a few units away from (and above) the origin of the scene, where I'll place my objects. I keep the default viewing direction, because it happens to point right where I want it to.

Define an Object

I'm going to start with a pretty simple scene -- just one object:

sub draw_view
{
    draw_axes();
}

sub draw_axes
{
    # Lines from origin along positive axes, for orientation
    # X axis = red, Y axis = green, Z axis = blue
    glBegin(GL_LINES);
    glColor(1, 0, 0);
    glVertex(0, 0, 0);
    glVertex(1, 0, 0);

    glColor(0, 1, 0);
    glVertex(0, 0, 0);
    glVertex(0, 1, 0);

    glColor(0, 0, 1);
    glVertex(0, 0, 0);
    glVertex(0, 0, 1);
    glEnd;
}

The lone object is itself quite simple, just three short lines extending from the origin along the X, Y, and Z axes. (I'm using "line" in the OpenGL sense, as a line segment, not the infinite line of rigorous mathematics.)

In OpenGL, when you want to define something to render, you must notify OpenGL when you begin and end the definition; these are the glBegin and glEnd calls. In addition, you must tell OpenGL what type of primitive you will use to create your object. There are several types of primitives, including points, lines, and triangles. In addition, each primitive type has variants based on how several primitives in a sequence connect (independently, connected in a strip, and so on). In this case, I use GL_LINES, indicating independently placed line segments.

I want each line to be a different color to make it easier to tell which is which. To set the current drawing color, I call glColor with an RGB (Red, Green, Blue) triplet. In OpenGL, each color component can range from 0 (none) to 1 (full). Therefore, (1, 0, 0) indicates pure red, (0, 1, 0) is pure green, and so on. A medium gray is (.5, .5, .5). For further mnemonic value, I assign the colors so that the RGB triplets match the coordinates of the endpoints of the lines -- red for the X axis, green for Y, and blue for Z.

For each line, after defining the color, I define the endpoints of the line using glVertex. Each line begins at the origin and extends one unit along the appropriate axis. In other words, this sequence defines a red line from (0, 0, 0) to (1, 0, 0):

    glColor(1, 0, 0);
    glVertex(0, 0, 0);
    glVertex(1, 0, 0);

With these routines in place, we finally have something to look at! As you can see, the X axis points to the right, the Y axis points up, and the Z axis points out of the screen toward the viewer (with OpenGL foreshortening it). Note the delay before the object first appears; that's because the sleep at the end of draw_frame creates a pause before end_frame syncs the screen with the drawing area.

Moving Boxes Around

Next, let's try a box. Anyone who's played a First Person Shooter game knows that their worlds have a surplus of boxes (a.k.a. "crates," "storage containers," and so on –- oddly, for storage containers, the larger they are, the less they seem to contain). I'll start with a simple cube and add another call for it to the end of draw_view:

sub draw_view
{
    draw_axes();
    draw_cube();
}

Defining the Cube

Here's the code that actually draws the cube:

sub draw_cube
{
    # A simple cube
    my @indices = qw( 4 5 6 7   1 2 6 5   0 1 5 4
                      0 3 2 1   0 4 7 3   2 3 7 6 );
    my @vertices = ([-1, -1, -1], [ 1, -1, -1],
                    [ 1,  1, -1], [-1,  1, -1],
                    [-1, -1,  1], [ 1, -1,  1],
                    [ 1,  1,  1], [-1,  1,  1]);

    glBegin(GL_QUADS);
    foreach my $face (0 .. 5) {
        foreach my $vertex (0 .. 3) {
            my $index  = $indices[4 * $face + $vertex];
            my $coords = $vertices[$index];
            glVertex(@$coords);
        }
    }
    glEnd;
}

That looks pretty hairy, but it's actually not bad. The @vertices array contains the coordinates for a cube two units on a side, centered at the origin, with its sides aligned with the X, Y, and Z axes. The @indices array defines which four vertices belong to each of the six faces of the cube and in what order to send them to OpenGL. The order is very important; I've arranged it so that, as seen from the outside, the vertices of each face draw in counterclockwise order. Using a consistent order helps OpenGL to determine the front and back side of each polygon; I've chosen to use the default counterclockwise order.

After defining those arrays, I mark the beginning of a series of independent quadrilateral primitives using glBegin(GL_QUADS). I then iterate through each vertex of each face, finding the correct set of coordinates and sending them to OpenGL using glVertex. Finally, I mark the end of this series of primitives using glEnd.

Colloquial Perl purists will no doubt wonder why I have chosen C-style loops (with the attendant index math, yuck), rather than making @indices an array of arrays. Mostly I'm just showing that it's not too hard to deal with this type of input data. When the engine reads object descriptions from files, rather than hand-coded routines, the natural output of the file parser may be flattened. It's often easier to do a little index math than to force the parser to output more structured data (and possibly more efficient too, but that's a clear call for benchmarking).

The result is one blue cube. Why blue? Since I never specified a new color to use, OpenGL went back to the current state and looked up the current drawing color. The last line in the axes was drawn in blue and that's still the current color. Hence one blue cube.

Two Colored Boxes

Let's fix that. At the same time, we can move the new cube out of the way of the axes so we can see them again. Heck, I'll go all out and have two cubes -- one to the left of the axis lines, and one to the right. The nice thing is that because I'm just drawing more of something I've already described, I just need to change draw_view:

sub draw_view
{
    draw_axes();

    glColor(1, 1, 1);
    glTranslate(-2, 0, 0);
    draw_cube();

    glColor(1, 1, 0);
    glTranslate( 2, 0, 0);
    draw_cube();
}

Now I set the current color to white using glColor(1, 1, 1) before drawing the first cube, and to yellow using glColor(1, 1, 0) before drawing the second cube. The glTranslate calls should place the first cube two units to the left (along the negative X axis) and the second cube two units to the right (along the positive X axis).

Pages: 1, 2, 3

Next Pagearrow