Sign In/My Account | View Cart  
advertisement


Listen Print Discuss

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

A Lantern

Of course, sometimes a light connected to the viewer is exactly the intention. For example, perhaps the desired effect is for the player to hold a lantern or flashlight to light dark places. Both of these are localized light sources that light nearby objects quite a bit, but distant objects only a little. The primary difference between them is that a flashlight and certain types of lanterns cast light primarily in one direction, often in a cone. Most lanterns, torches, and similar light sources cast light in all directions (barring shadows from handles, fuel tins, and the like).

Non-directed light is a little simpler to implement, so I'll start with lantern light. I wanted the light rooted at the viewer's position, so I defined the light before setting the view:

sub draw_frame
{
    my $self = shift;

    $self->set_projection_3d;
    $self->set_eye_lights;
    $self->set_view_3d;
    $self->set_world_lights;
    $self->draw_view;
}

I refer to viewer-fixed lights as eye lights because OpenGL refers to the coordinate system it uses for lights as eye coordinates, and a light defined this way as maintaining a particular position "relative to the eye." Here's set_eye_lights:

sub set_eye_lights
{
    glLight(GL_LIGHT1, GL_POSITION, 0.0, 0.0, 1.0, 0.0);

    glEnable(GL_LIGHT1);
}

Here I set the second light exactly the same way I set the first. Note that it doesn't matter that I actually define the second light in my program before the first. Each OpenGL light is independently numbered and always keeps the same number, rather than acting like a stack or queue numbered by order of use.

Sadly, the new code doesn't seem to have any effect at all. In reality, there really is a new light shining on the scene--unlike GL_LIGHT0, which defaults to shining bright white, all of the other lights default to black and provide no new light to the scene. The solution is to set another parameter of the light:

sub set_eye_lights
{
    glLight(GL_LIGHT1, GL_POSITION, 0.0, 0.0, 1.0, 0.0);
    glLight(GL_LIGHT1, GL_DIFFUSE,  1.0, 1.0, 1.0, 1.0);

    glEnable(GL_LIGHT1);
}

The front faces of each object should appear considerably brighter. Moving around the scene shows that the eye light brightens a surface only dimly lit by the world light:

If you watch carefully, however, you'll notice that the lighting varies by the view rotation--not position. I defined the light as directional with the light coming from behind the viewer, rather than positional, with the light coming from the viewer directly. I hinted at the fix earlier--changing the GL_POSITION parameter as follows:

    glLight(GL_LIGHT1, GL_POSITION, 0.0, 0.0, 0.0, 1.0);

The light now comes from (0, 0, 0) in eye coordinates, right at the viewpoint. Moving around and rotating shows that this version has the intended effect.

The simulated lantern still shines as brightly on far-away objects as it does on near ones. A real lantern's light falls off rapidly with distance from the lantern. OpenGL can do this with another setting:

sub set_eye_lights
{
    glLight(GL_LIGHT1, GL_POSITION, 0.0, 0.0, 0.0, 1.0);
    glLight(GL_LIGHT1, GL_DIFFUSE,  1.0, 1.0, 1.0, 1.0);
    glLight(GL_LIGHT1, GL_LINEAR_ATTENUATION, 0.5);

    glEnable(GL_LIGHT1);
}

This case tells OpenGL to include a dimming term in its equations proportional to the distance between the light and the object. Physics-minded readers will point out that physically accurate dimming is proportional to the square of the distance, and OpenGL does allow this using GL_QUADRATIC_ATTENUATION. However, a host of factors (including the lighting equations that OpenGL uses and the non-linear effects of the graphics hardware, monitor, and human eye) make this more accurate dimming look rather odd. Linear dimming turns out to look better in many cases, so that's what I used here. It is also possible to combine different dimming types, so that the dimming appears linear for nearby objects and quadratic for distant ones, which you may find a better tradeoff. The 0.5 setting tells OpenGL how strong the linear dimming effect should be for my scene.

Moving around the scene, you should be able to see the relatively subtle dimming effect in action. Don't be afraid to leave it subtle instead of turning the dimming effect way up. Some moods call for striking lighting effects, while others call for lighting effects that the viewer notices only subconsciously. In some visualization applications, lighting subtlety is a great virtue, allowing the human visual system's amazing processing power to come to grips with a complex scene without being overwhelmed.

A Flashlight

I really happen to like the way a flashlight casts its cone of light, so I converted the omnidirectional light of the lantern to a directed cone. OpenGL refers to this type of light as a spotlight and includes several light parameters to define them. The first change is a new setting in set_eye_lights:

    glLight(GL_LIGHT1, GL_SPOT_CUTOFF, 15.0);

This sets the angle between the center of the light beam and the edges of the cone. OpenGL accepts either 180 degrees (omnidirectional) or any value between 0 and 90 degrees (from a laser beam to a hemisphere of light). In this case, I chose a centerline-to-edge angle of 15 degrees, making a nice 30-degree-wide cone of light.

This change indeed limits the cone of light, but also reveals an ugly artifact. Move to a point just in front of the left front corner of the white cube and rotate the view to pan the light across the yellow box. You'll see the light jump nastily from corner to corner, even disappearing entirely in between. Even when a corner is lit, the shape of the light is not very conelike:

OpenGL's standard lighting model only performs the lighting calculations at each vertex, interpolating the results in between. For models that have many small faces and a resulting high density of vertices, this works relatively well. It breaks down nastily in scenes containing objects with large faces and few vertices, especially when a positional light is close to an object. Spotlights make the problem even more apparent, as they can easily shine between two vertices without lighting either of them; the polygon then appears uniformly dark.

Ode to Rush

Advanced OpenGL functionality paired with recent hardware can solve this problem with per-pixel lighting calculations. Older hardware can fake it with light maps and similar tricks. Rather than using advanced functionality, I'll use a simpler method for improving the lighting, known as subdivisions. (Those of you scratching your heads over the Rush reference can now breathe a collective sigh of relief.) Subdivisions have their own problems, as I'll show later, but those issues explain a lot about the design of graphics APIs, so they're worth a look.

As the name implies, the basic idea is to subdivide each face into many smaller faces, each with its own set of vertices. For curved objects such as spheres and cylinders, this is essential so that nearby objects appear to curve smoothly. For objects with large flat faces, such as boxes and pyramids, this merely has the side effect of forcing the per-vertex lighting calculations to be done many times across each face.

Before I can use subdivided faces, I need to prepare by refactoring draw_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]);
    my @normals = ([0, 0,  1], [ 1, 0, 0], [0, -1, 0],
                   [0, 0, -1], [-1, 0, 0], [0,  1, 0]);

    foreach my $face (0 .. 5) {
        my $normal = $normals[$face];
        my @corners;

        foreach my $vertex (0 .. 3) {
            my $index  = $indices[4 * $face + $vertex];
            my $coords = $vertices[$index];
            push @corners, $coords;
        }
        draw_quad_face(normal    => $normal,
                       corners   => \@corners);
    }
}

Instead of performing the OpenGL calls directly in draw_cube, it now calls draw_quad_face. For each large face it creates a new @corners array filled with the vertex coordinates of the corners of that face. It then passes that array and the face normal to draw_quad_face, defined as follows:

sub draw_quad_face
{
    my %args    = @_;
    my $normal  = $args{normal};
    my $corners = $args{corners};

    glBegin(GL_QUADS);
    glNormal(@$normal);

    foreach my $coords (@$corners) {
        glVertex(@$coords);
    }
    glEnd;
}

This function performs exactly the OpenGL operations that draw_cube used to do. I've also used a different argument-passing style for this routine than I have previously. In this case, I pass named arguments because I know that I will add at least one more argument very soon and that there's a pretty good chance I'll want to add more later. When the arguments to a routine are likely to change over time, and especially when callers might want to specify only a few arguments and allow the rest to take on reasonable defaults, named arguments are usually a better choice. The arguments can either be a hashref or a list stuffed into a hash. This time, I chose the latter method.

After refactoring comes testing, and a quick run showed that everything worked as expected. Safe in that knowledge, I rewrote draw_quad_face to subdivide each face:

sub draw_quad_face
{
    my %args    = @_;
    my $normal  = $args{normal};
    my $corners = $args{corners};
    my $div     = $args{divisions} || 10;
    my ($a, $b, $c, $d) = @$corners;

    # NOTE: ASSUMES FACE IS A PARALLELOGRAM

    my $s_ab = calc_vector_step($a, $b, $div);
    my $s_ad = calc_vector_step($a, $d, $div);

    glNormal(@$normal);
    for my $strip (0 .. $div - 1) {
        my @v = ($a->[0] + $strip * $s_ab->[0],
                 $a->[1] + $strip * $s_ab->[1],
                 $a->[2] + $strip * $s_ab->[2]);

        glBegin(GL_QUAD_STRIP);
        for my $quad (0 .. $div) {
            glVertex(@v);
            glVertex($v[0] + $s_ab->[0],
                     $v[1] + $s_ab->[1],
                     $v[2] + $s_ab->[2]);

            $v[0] += $s_ad->[0];
            $v[1] += $s_ad->[1];
            $v[2] += $s_ad->[2];
        }
        glEnd;
    }
}

The new routine starts by adding the new optional argument divisions, which defaults to 10. This specifies how many subdivisions the face should have both "down" and "across"; the actual number of sub-faces is the square of this number. For the default 10 divisions, that comes to 100 sub-faces for each large face, so each cube has 600 sub-faces.

The next line labels the corners in counterclockwise order. This puts corner A diagonally across from corner C, with B on one side and D on the other.

As the comment on the next line indicates, I've simplified the math considerably by assuming that the face is at least a parallelogram. With this simplification, I can calculate the steps for one division along sides AB and AD and use these steps to position every sub-face across the entire large face.

I can't just calculate the step as a simple distance to move, because I have no idea which direction each edge is pointing and wouldn't know which way to move for each step. Instead, I calculate the vector difference between the vertices at each end of the edge and divide that by the number of divisions. The code does the same calculation twice, so I've extracted it into a separate routine:

sub calc_vector_step
{
    my ($v1, $v2, $div) = @_;

    return [($v2->[0] - $v1->[0]) / $div,
            ($v2->[1] - $v1->[1]) / $div,
            ($v2->[2] - $v1->[2]) / $div];
}

Returning to draw_quad_face, it stores the vector steps in $s_ab (the step along the AB side) and $s_ad (the step along the AD side). Next it sets the current normal, which for a flat face remains the same across its entirety.

Finally, I can begin to define the sub-faces themselves. I've taken advantage of the OpenGL quad strip primitive to draw the sub-faces as a series of parallel strips extending from the AB edge to the CD edge. For each strip, I first need to calculate the location of its starting vertex. I know this is on the AB edge, so the code starts at A and adds an AB step for each completed strip. For the first strip, this puts the starting vertex at A. For the last strip, the starting vertex will be one step (one strip width) away from B. It initializes the current vertex @v with the starting vertex and will keep it updated as it moves along each strip.

It then begins a strip of quads with glBegin(GL_QUAD_STRIP). To define the strip, I've specified the locations of each pair of vertices across from each other along its length. For each pair, it uses the current vertex and a calculated vertex one step further along the AB direction. The code then moves the current vertex one step along the length of the strip (the AD direction). Once the strip is complete, it ends it with glEnd and loops again for the next strip.

All of this complexity makes quite a visual difference:

It's clear that the light has a definite shape to it, but the lighting is so jagged that it's distracting. One way to fix this is to increase the number of divisions, making smaller sub-faces. This requires a simple addition to the draw_quad_face call in draw_cube:

        draw_quad_face(normal    => $normal,
                       corners   => \@corners,
                       divisions => 30);

The result is quite a bit less jagged:

Unfortunately, the jaggies are smaller but still obviously there--and the closer the viewer is to an object the bigger they appear. There are also nine times as many sub-faces to draw (30/10 squared) and the program now runs considerably slower. If you're lucky enough to have a recent system with fast video hardware and don't notice the slowdown, use 100 or so for the number of divisions. You'll probably see it.

Pages: 1, 2, 3, 4, 5

Next Pagearrow