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:
- Choose a projection, so that OpenGL knows how I want to look at the scene.
- Set the view, so OpenGL knows from which direction to view the scene (the viewpoint) and in which direction I wish to look.
- 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).

