Sign In/My Account | View Cart  
advertisement


Listen Print Discuss

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

Refactoring for Fun and Clarity

Now that the animation is smooth, I'm almost ready to add some manual control using SDL events. That's a big topic and involves a lot of code. It's always a good idea before a big change to step back, take a look at the exisiting code, and see if it needs a clean up.

The basic procedure is as follows:

  • Find one obvious bit of ugliness.
  • Make a small atomic change to clean it up.
  • Test to make sure everything still works.
  • Lather, rinse, repeat until satisfied.

Unfortunately, it's occasionally necessary to make one part of the code a little uglier while cleaning up something else. The trick is eventually to clean up the freshly uglified piece.

Refactoring the View

And on that note, let's add another global! I don't like the hardcoding of set_view_3d. I'd like to convert that into a data structure of some kind, so I define a view object:

my ($view);

This needs an update routine, so here's a simple one:

sub update_view
{
    $view = {
        position    => [6, 2, 10],
        orientation => [-90 + 36 * $time, 0, 1, 0],
    };
}

This is simply the position and orientation of the virtual viewer (before the sign reversal needed by the viewing transformations). I need to call this in the main loop, just before calling do_frame:

sub main_loop
{
    while (not $done) {
        $frame++;
        update_time();
        update_view();
        do_frame();
    }
}

At this point, running the program should show that nothing much changed because I haven't actually changed the viewing code--the new code runs in parallel with the old code. Using the new code requires replacement of set_view_3d:

sub set_view_3d
{
    my ($angle, @axis) = @{$view->{orientation}};
    my ($x, $y, $z)    = @{$view->{position}};

    glRotate(-$angle, @axis);
    glTranslate(-$x, -$y, -$z);
}

Running the program should again show that nothing visually changed, indicating a successful refactoring. At this point, you may wonder what this has gained; there's a new global and a dozen or so more lines of code. The new code has several benefits:

  • The concepts of animating the view parameters and setting the current view in OpenGL are now separate, as they should be.
  • Hoisting the update_view call up to main_loop next to the update_time call begins to collect all updates together, cleaning up the overall design.
  • The new code hints at further refactoring opportunities.

In fact, I can see several problem places to refactor next, along with some reasons to fix them:

  1. The mess of globals, since I just added one.
  2. Updating $done in draw_view (mixing updates and OpenGL work again) to continue collecting all updates together.
  3. Pervasive hardcoding in draw_view, for all the same reasons I refactored set_view_3d.

The Great Global Smashing

The globals situation is out of hand, so now seems a good time to fix that and check off the first item of the new "pending refactoring" list. First, I need to decide how to address the problem.

Here's the current globals list:

my ($done, $frame);
my ($conf, $sdl_app);
my ($time);
my ($view);

In this mess, I see several different concepts:

  • Configuration: $conf
  • Resource object: $sdl_app
  • Engine state: $done, $frame
  • Simulated world: $time, $view

I'll create from these groupings a single engine object laid out like so (variables show where the data from the old globals goes):

{
    conf     => $conf,
    resource => {
        sdl_app => $sdl_app,
    },
    state    => {
        done    => $done,
        frame   => $frame,
    },
    world    => {
        time    => $time,
        view    => $view,
    }
}

This is a fairly radical change from the current code, so to be safe, I'll do it in several small pieces, testing after each version that everything still works.

The first step is to add a constructor for my object:

sub new
{
    my $class = shift;
    my $self  = bless {}, $class;

    return $self;
}

This is pretty much the garden variety trivial constructor in Perl 5. It blesses a hash reference into the specified class and then returns it. I also need to change my START code to use this new constructor to create an object and call main as a method on it:

START: __PACKAGE__->new->main;

This snippet constructs a new object, using the current package as the class name, and immediately calls the main method on the returned object. main doesn't have any parameters, so calling it as a method won't affect it (there are no existing parameters that would be shifted right by a new invocant parameter). I never store the object in a variable as a form of self-imposed stricture. Because the object only exists as the invocant of main, I must convert every routine that accesses the information in the object to a method and change all calls to those routines as well.

Let It Flow

Testing at this point shows all still works, so the next change is to make main flow the object reference through to its children by calling them as methods:

sub main
{
    my $self = shift;

    $self->init;
    $self->main_loop;
    $self->cleanup;
}

Testing this shows that, again, all is fine, as expected. init and main_loop are changed in the obvious fashion (cleanup doesn't do much, so it doesn't need to change now):

sub init
{
    my $self = shift;

    $| = 1;

    $self->init_conf;
    $self->init_window;
}

sub main_loop
{
    my $self = shift;

    while (not $done) {
        $frame++;
        $self->update_time;
        $self->update_view;
        $self->do_frame;
    }
}

Notice that I have not changed the references to $done and $frame in main. It's important to make only a single conceptual change at a time during refactoring, to minimize the chance of making an error and not being able to figure out which change caused the problem. I'll return to these references in a bit. Testing this version shows that all is well, so I continue:

sub do_frame
{
    my $self = shift;

    $self->prep_frame;
    $self->draw_frame;
    $self->end_frame;
}

sub draw_frame
{
    my $self = shift;

    $self->set_projection_3d;
    $self->set_view_3d;
    $self->draw_view;

    print '.';
    $done = 1 if $time >= 5;
}

Again, for this pass, I ignore $done and $time in draw_frame. At this point, I've pretty much exhausted all of the changes that amount to simply turning subroutine calls into method calls and the code still works as advertised.

Replacing the Globals

With this working, I start into more interesting territory. It's time to move the globals into their proper place in the object. First up are the state variables $done and $frame in main_loop:

sub main_loop
{
    my $self = shift;

    while (not $self->{state}{done}) {
        $self->{state}{frame}++;
        $self->update_time;
        $self->update_view;
        $self->do_frame;
    }
}

and the last line of draw_frame:

$self->{state}{done} = 1 if $time >= 5;

As they are no longer globals, I remove their declarations as well. I will have to come back to draw_frame again when cleaning up $time. One change per pass--it's very easy to follow a long chain of related changes before doing a test run and find out you made a mistake. Somewhere. Argh. In this case, I resist the urge to keep changing the code and do another test run immediately to find that indeed all still works.

Next up is the world attribute $view:

sub update_view
{
    my $self = shift;

    $self->{world}{view} = {
        position    => [6, 2, 10],
        orientation => [-90 + 36 * $time, 0, 1, 0],
    };
}

sub set_view_3d
{
    my $self = shift;

    my $view           = $self->{world}{view};
    my ($angle, @axis) = @{$view->{orientation}};
    my ($x, $y, $z)    = @{$view->{position}};

    glRotate(-$angle, @axis);
    glTranslate(-$x, -$y, -$z);
}

In set_view_3d, it seemed clearest to make a lexical $view loaded from the object. This allowed me to leave the rest of the function clean and unchanged. Testing after the above changes and removing the global declaration for $view shows that all is good.

Next up are $conf and the resource object $sdl_app, following much the same pattern as before:

sub init_conf
{
    my $self = shift;

    $self->{conf} = {
        title  => 'Camel 3D',
        width  => 400,
        height => 400,
        fovy   => 90,
    };
}

sub init_window
{
    my $self = shift;

    my $title = $self->{conf}{title};
    my $w     = $self->{conf}{width};
    my $h     = $self->{conf}{height};

    $self->{resource}{sdl_app}
        = SDL::App->new(-title  => $title,
                        -width  => $w,
                        -height => $h,
                        -gl     => 1,
                       );
    SDL::ShowCursor(0);
}

sub set_projection_3d
{
    my $self   = shift;

    my $fovy   = $self->{conf}{fovy};
    my $w      = $self->{conf}{width};
    my $h      = $self->{conf}{height};
    my $aspect = $w / $h;

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

    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity;
}

sub end_frame
{
    my $self = shift;

    $self->{resource}{sdl_app}->sync;
}

The first time I did this, it broke. I had forgotten to make the changes to set_projection_3d. Thanks to use strict, the error was obvious, and a quick fix later, everything worked again.

Last but not least, it's time to fix the remaining world attribute $time:

sub update_time
{
    my $self = shift;

    $self->{world}{time} = now();
}

In update_view, I continue with my tactic of creating lexicals and leaving the remaining code alone:

my $time = $self->{world}{time};

Finally, the last line of draw_frame changes again:

$self->{state}{done} = 1
    if $self->{world}{time} >= 5;

The first test run of the completed refactoring uncovered a typo that gave an obscure warning. Thankfully, I only had to check the few changed lines since the last test, and the typo was easily found. With things working again, The Great Global Smashing is complete. The once completely procedural program is now on its way to claiming object orientation. (Boy will I be happy to switch to Perl 6 OO syntax! Perl 6 OO keeps the visual clarity of pure procedural code while gaining several powerful benefits not available in Perl 5. I could fake the clearer syntax with judicious use of source filtering, but that's another article.)

This seems to me like enough refactoring for now, so it's back to the main thrust of development: keyboard control.

Pages: 1, 2, 3, 4, 5

Next Pagearrow