使用Perl构建3D引擎,第二部分

本文是构建一个完整Perl 3D引擎系列的第二篇文章。第一篇文章《使用Perl构建3D引擎》涵盖了基本程序结构、使用SDL打开OpenGL窗口、基本的投影和视图设置、简单的对象渲染、对象变换以及使用OpenGL深度缓冲区的深度排序。

编辑注解:请参阅本系列的其余部分,包括光照和运动应用程序性能分析

这次,我将讨论旋转和动画视图、SDL事件和键盘处理,以及补偿帧率变化。作为额外内容,我将演示一些实际的重构,包括从过程式代码到(弱)面向对象的代码的转换。然而,在开始之前,自第一篇文章发布以来发现了一些问题

  • 在第一篇文章中,我写了“正交投影”。这应该是“正射投影”,这再次提醒我,无论你校对多少次,你仍然可能会错过代码或文字中的错误。不幸的是,文字的测试比较难写。
  • Todd Ross在FreeBSD 5.3上发现了一个与SDL相关的问题,导致代码立即崩溃,显示“错误的系统调用(核心转储)”。不久之后,他报告了解决方案。他通过设置一个带有一些魔法的LD_PRELOAD环境变量,一切恢复正常。

    setenv LD_PRELOAD /usr/lib/libc_r.so
    

    如果你遇到类似的问题,可以遵循他的研究方法。他安装了另一个SDL_Perl应用程序,在这种情况下是Frozen Bubble。一旦他确认它工作正常,他就检查了启动脚本中的代码,并找到了上面的魔法。快速测试确认这对他自己的代码也有效。

    Frozen Bubble是一个2D应用程序,所以如果你的OpenGL程序工作正常,但你的OpenGL程序没有,请确保OpenGL确实可以工作。Unix变体应该提供glxinfoglxgears程序。使用glxinfo来收集你的OpenGL驱动程序的详细信息;它既是一种理智的检查,也是很好的错误报告补充。glxgears执行一个简单的齿轮网格的动画渲染。这告诉你OpenGL是否正确工作(至少对于基本内容)以及你的OpenGL驱动程序和硬件可以提供什么性能。这两个程序在苹果的OS X 10.3上的X窗口系统下也能工作。

请继续提出您的评论、问题和错误报告!我希望能在下篇文章中认可您的贡献,但如果你希望保持匿名,那也行。

现在不再拖延,让我们开始吧。如果您想在不输入所有代码的情况下尝试每个阶段的代码,请下载示例源代码。它包括一个README.steps文件,应该有助于您更容易地跟随。

移动视点

在上篇文章的最后,我们的场景中有一组大致位于屏幕中心的坐标轴线,后面有一个大白色立方体,右边有一个旋转的黄色平面盒子

sub draw_view
{
    draw_axes();

    glColor(1, 1, 1);
    glPushMatrix;
    glTranslate( 0, 0, -4);
    glScale( 2, 2, 2);
    draw_cube();
    glPopMatrix;

    glColor(1, 1, 0);
    glPushMatrix;
    glTranslate( 4, 0, 0);
    glRotate( 40, 0, 0, 1);
    glScale(.2, 1, 2);
    draw_cube();
    glPopMatrix;
}

让我们通过更改第一个glTranslate调用来将白色立方体向右移动

glTranslate( 12, 0, -4);

现在窗口的右边切掉了白色盒子。如果我想在不改变所有对象相对位置的情况下解决这个问题,我可以进行一些可能的更改

  • 使用更宽的投影(FOV)角度可以一次看到更多的场景。不幸的是,它已经达到90度,相当宽。透视效果已经很强烈;再宽的话,渲染将看起来过于扭曲。
  • 将场景中的所有对象分别向左移动相同的距离。这当然可以工作,但需要付出很多努力,尤其是场景中有很多对象时。
  • 将视点向右移动以重新定位视图。这是我的首选。

我想将视点向右移动,即正X方向,所以我将观看变换的X分量加6

sub set_view_3d
{
    glTranslate(6, -2, -10);
}

现在场景甚至更向右了。问题是OpenGL将用于修改视图(观看变换)的变换与用于转换场景中对象的变换(建模变换)组合到模型视图矩阵中。OpenGL无法知道我是否希望任何给定的模型视图变换改变视图或场景中的对象;它将它们都视为改变对象。通过平移+6X,我实际上将每个对象向右移动了6个单位,而不是像预期的那样向右移动视点。

我之前已经暗示了解决方案:将视点向右移动相当于将场景中的所有对象向左移动。解决这个问题的方法是反转平移的符号

sub set_view_3d
{
    glTranslate(-6, -2, -10);
}

这使视点位于(6,2,10),这是我想要的,大致重新定位了场景。现在你可以看到为什么第一篇文章中的观看变换将视点移到了原点上方(+Y)和离用户一定距离的位置(+Z)。我只是反转了我想要的视点坐标的符号,(0,2,10)。

现在场景已居中,但使用这种静态视图,很难判断场景中对象的真实位置和相对大小。也许我可以稍微旋转一下视图来看看。我将绕Y轴顺时针旋转90度(正旋转)

sub set_view_3d
{
    glTranslate(-6, -2, -10);
    glRotate(90, 0, 1, 0);
}

好吧,这确实旋转了东西,但仍然很难看到对象的确切位置。为什么场景会向左全部移位,轴线的线又在前面?

动画视图

为了理解奇怪变换的真正情况,我将其变成一个简短的动画。我以非常小的变换开始动画,并继续增加直到超过预期的水平。这样,我可以看到较小和较大变化的影响。

为此,我需要在动画中添加更多帧。我可以通过更改draw_frame中的最后一行来实现这一点

$done = 1 if $frame == 10;

我还想使旋转与每一帧一起动画化

sub set_view_3d
{
    glTranslate(-6, -2, -10);
    glRotate(18 * $frame, 0, 1, 0);
}

这把旋转分成18度的增量,从第1帧的18度旋转开始,到第10帧的180度旋转结束。

运行此程序显示了正在发生的事情。场景绕其原点逆时针旋转,轴线的交点。我想旋转视图,但我旋转了对象。只是反转符号不起作用。这将使场景向相反方向(顺时针)旋转,但它不会围绕视点旋转——它仍然会围绕场景原点旋转。

在第一篇文章中,我描述了如何通过考虑对象的一系列步骤来转换局部坐标系来可视化一系列变换。查看上面的代码,你可以看到它首先将场景原点移动开去,然后围绕新的原点旋转。为了围绕视点旋转,我需要先旋转然后移动场景。

sub set_view_3d
{
    glRotate(18 * $frame, 0, 1, 0);
    glTranslate(-6, -2, -10);
}

现在它围绕着视点旋转,但由于它从正前方开始旋转180度,场景最终出现在视点后面。为了使场景在一侧,然后旋转到另一侧,我只需简单地偏移角度。

sub set_view_3d
{
    glRotate(-90 + 18 * $frame, 0, 1, 0);
    glTranslate(-6, -2, -10);
}

在第1帧,旋转角度是 -90 + 18 * 1 = -72度。在第10帧,角度是 -90 + 18 * 10 = 90度。完美。

停止并转身

只有一个小问题:它走的方向错了!我想进行逆时针旋转视图,但这样会使场景看起来在视点周围顺时针旋转。想象一下站在一个地标的前面,拍照。通过取景器看,你可能会注意到地标有点偏离中心。为了将其居中,稍微向左转(从上方看为逆时针,或在默认的OpenGL坐标系统中为+Y)。这将使地标看起来顺时针绕你移动(再次从上方看),将其从取景器的左侧移动到中心。

在这种情况下,反转角度的符号

sub set_view_3d
{
    glRotate(90 - 18 * $frame, 0, 1, 0);
    glTranslate(-6, -2, -10);
}

实际上,视图的每一次变换都与场景中每个对象的相反变换等价。你必须反转平移中的坐标符号,反转旋转中的角度符号,或在缩放中取因素的逆(将观察者缩小到一半大小会使所有东西看起来大两倍)。正如我们之前看到的,你必须反转旋转和平移的顺序。

缩放是一个特殊情况。反转因子是有效的,但你仍然必须在转换后进行缩放以达到预期的效果,而不是遵循旋转和平移的规则并完全反转转换顺序。原因是转换前的缩放也会缩放转换。通过(2, 2, 2)缩放会将场景中所有对象的大小加倍,但也会使它们远离两倍的距离,使它们看起来大小相同。我将跳过这段代码,将其留给读者作为练习。去试试吧,享受乐趣。

如果你决定尝试视图缩放,请记住所有距离都会改变。这会影响一些不明显的事情,如gluPerspective的第三和第四个参数(OpenGL将渲染的最近和最远对象的距离)。

使其平滑

观看这些动画一段时间后,卡顿真的很让我烦恼,因为我加倍了动画帧的数量,完成一个运行需要的时间也加倍了。这两个问题都与draw_frame末尾的第二秒睡眠有关。我应该可以通过将睡眠缩短到半秒来修复这些问题。

sleep .5;

很可能,这不会产生完全满意的结果。在我的系统上,会有几秒钟的模糊,然后整个运行就完成了。不幸的是,内建的Perl sleep函数只处理整数秒,所以.5被截断为0,sleep几乎立即返回。

幸运的是,SDL提供了一个毫秒级延迟函数SDL::Delay。要使用它,我添加了另一个子例程来处理延迟,在秒和毫秒之间进行转换

sub delay
{
    my $seconds = shift;

    SDL::Delay($seconds * 1000);
}

现在,将sleep调用更改为delay可以修复这个问题

delay(.5);

移动更快了,整个动画只需要五秒钟就可以完成,但这段代码仍然浪费了系统的可用性能。我想使动画尽可能平滑,同时保持旋转速度(和总时间)不变。为了实现这一点,我需要给代码一个时间感。首先,我添加了另一个全局变量来保持当前时间

my ($time);

在这个阶段,我的编辑器可能不小心把屏幕喷上了他喝的东西,然后咳嗽着说“另一个全局?!?”我会在文章重构过程中稍后解决这个问题。

为了更新时间,我需要几个额外的函数

sub update_time
{
    $time = now();
}

sub now
{
    return SDL::GetTicks() / 1000;
}

now 函数调用 SDL 的 GetTicks 函数,它返回自 SDL 初始化以来的时间(以毫秒为单位)。为了方便其他地方使用,它将结果转换回秒。update_time 使用 now 来保持全局变量 $time 的更新。

main_loop 在渲染帧之前使用这个时间来更新时间

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

因为这个版本不会人为地减慢动画速度,我对 draw_frame 做了两个更改。我移除了 delay 调用,并将动画结束测试改为检查时间是否达到五秒,而不是是否已经绘制了第十帧。

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

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

最后,set_view_3d 必须基于当前时间而不是当前帧来进行动画。我们当前的旋转速度是每帧 18 度。以每秒 2 帧的速度,这相当于每秒 36 度。

sub set_view_3d
{
    glRotate(90 - 36 * $time, 0, 1, 0);
    glTranslate(-6, -2, -10);
}

这个版本应该看起来要平滑得多。在我的系统上,每个帧打印的点会在终端窗口向上滚动。如果你运行这个程序多次,你会注意到帧的数量(以及因此的点数)会有所不同。来自众多来源的时间微小变化会导致偶尔帧的耗时更多或更少。在整个运行过程中,这会导致在达到五秒截止时间之前可以多或少完成几个帧。从视觉上看,旋转速度应该看起来几乎恒定,因为它从当前时间(无论是什么)计算当前角度,而不是帧数。

为了乐趣和清晰性重构

现在动画已经平滑,我几乎准备好添加一些使用 SDL 事件的手动控制了。这是一个很大的主题,涉及到很多代码。在做出大的更改之前,退一步查看现有代码,看看是否需要清理,总是一个好主意。

基本步骤如下

  • 找到一处明显的丑陋之处。
  • 进行小的原子性更改来清理它。
  • 测试以确保一切仍然正常工作。
  • 重复以上步骤,直到满意为止。

不幸的是,在清理其他东西的时候,有时需要让代码的一部分稍微丑陋一些。技巧是最终清理那些刚刚变得丑陋的部分。

重构视图

关于这一点,让我们再添加一个全局变量!我不喜欢 set_view_3d 的硬编码。我希望将其转换为某种类型的数据结构,所以我定义了一个视图对象

my ($view);

这需要一个更新程序,所以这里有一个简单的例子

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

这仅仅是虚拟观察者的位置和方向(在视图变换所需的符号反转之前)。我需要在主循环中调用这个函数,在调用 do_frame 之前

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

此时,运行程序应该显示没有太多变化,因为我实际上并没有改变视图代码——新代码与旧代码并行运行。使用新代码需要在 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);
}

再次运行程序应该显示视觉上没有任何变化,这表明重构成功。在这个阶段,你可能想知道这得到了什么;有一个新的全局变量和大约十几行额外的代码。新代码有几个好处

  • 动画视图参数的概念和设置 OpenGL 中当前视图现在是分开的,就像它们应该是的那样。
  • update_view 调用提升到 main_loop 中,与 update_time 调用并排,开始收集所有更新,清理整体设计。
  • 新代码暗示了更多的重构机会。

实际上,我可以看到几个需要重构的地方,以及一些修复它们的原因

  1. 全局变量的混乱,因为我刚刚添加了一个。
  2. draw_view 中更新 $done(再次混合更新和OpenGL工作)以继续收集所有更新。
  3. draw_view 中普遍存在硬编码,原因和我在重构 set_view_3d 时的理由相同。

全球大崩溃

全局变量的情况已经失控,因此现在似乎是修复它并勾选新“待重构”列表的第一项的好时机。首先,我需要决定如何解决这个问题。

以下是当前的全局变量列表

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

在这个混乱中,我看到了几个不同的概念

  • 配置:$conf
  • 资源对象:$sdl_app
  • 引擎状态:$done$frame
  • 模拟世界:$time$view

我将从这些分组中创建一个单一的引擎对象,如下所示(变量显示了旧全局变量中的数据位置)

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

这是一个相当激进的变更,所以为了安全起见,我将分几个小部分来做,并在每个版本测试后确保一切仍然工作。

第一步是给我的对象添加一个构造函数

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

    return $self;
}

这基本上是Perl 5中的普通平凡构造函数。它将哈希引用祝福为指定的类,然后返回它。我还需要更改我的START代码以使用这个新构造函数来创建一个对象,并在上面调用main方法

START: __PACKAGE__->new->main;

此片段构建了一个新的对象,使用当前包作为类名,并立即在返回的对象上调用main方法。main没有参数,因此作为方法调用不会影响它(没有现有的参数会因为新调用者参数而右移)。我从不存储对象在变量中,作为一种自我强制的规则。因为对象只存在于main的调用者中,所以我必须将访问对象信息的每个例程转换为方法,并更改所有对这些例程的调用。

让它流动

到此为止的测试表明一切正常,所以下一个更改是将对象引用通过调用它们作为方法传递给main

sub main
{
    my $self = shift;

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

对此进行测试表明,正如预期的那样,一切正常。《init和main_loop按明显的方式更改(cleanup不做什么,所以现在不需要更改)

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;
    }
}

请注意,我没有更改main$done$frame的引用。在重构期间,每次只进行一个概念上的更改非常重要,以最大限度地减少犯错误的机会并无法确定哪个更改导致了问题。我稍后会返回到这些引用。对这个版本的测试表明一切正常,所以我继续进行。

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;
}

同样,在这个过程中,我忽略了draw_frame中的$done$time。到现在为止,我基本上已经用完了一切仅涉及将子例程调用转换为方法调用且代码仍然按预期工作的变更。

替换全局变量

有了这个正常工作的结果,我开始进入更有趣的领域。是时候将全局变量移动到对象中的正确位置了。首先,是main_loop中的状态变量$done$frame

sub main_loop
{
    my $self = shift;

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

以及draw_frame的最后一条语句

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

由于它们不再是全局变量,我也移除了它们的声明。在清理 $time 时,我必须再次回到 draw_frame。每次迭代一个更改——在测试运行之前跟随一系列相关更改非常容易,但发现你犯了一个错误。在某个地方。哎呀。在这种情况下,我忍住了继续更改代码的冲动,立即进行了另一次测试运行,发现确实一切正常。

接下来是世界的属性 $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);
}

set_view_3d 中,从对象中加载词法 $view 似乎是最清晰的。这使得我可以保持其余函数干净且未更改。在上述更改之后进行测试,并移除 $view 的全局声明,显示一切良好。

接下来是 $conf 和资源对象 $sdl_app,与前一种模式非常相似

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;
}

第一次做这件事时,它坏了。我忘记更改 set_projection_3d 中的更改。多亏了 use strict,错误很明显,稍作修复后,一切又恢复正常。

最后但同样重要的是,是时候修复剩余的世界属性 $time

sub update_time
{
    my $self = shift;

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

update_view 中,我继续使用我的策略创建词法,并保持其余代码不变

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

最后,draw_frame 的最后一行再次更改

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

对已完成重构的第一次测试运行发现了一个导致模糊警告的拼写错误。幸运的是,我只需要检查自上次测试以来更改的几行,拼写错误就很容易找到了。一切恢复正常后,伟大的全局破坏就完成了。曾经完全过程的程序现在正朝着面向对象的方向发展。(我将非常高兴切换到 Perl 6 面向对象语法!Perl 6 面向对象语法保持了纯过程代码的视觉清晰度,同时获得了在 Perl 5 中不可用的几个强大功能。我可以通过使用源过滤巧妙地伪造更清晰的语法,但这将是另一篇文章的内容。)

在我看来,这已经足够重构了,所以又回到了开发的主线:键盘控制。

大事件

键盘处理是 SDL 事件处理的特殊情况,而且并非完全简单。我将从处理 SDL 事件的基本结构开始,首先处理一个更简单的事件。要访问 SDL 事件,我需要加载 SDL::Event 模块

use SDL::Event;

SDL::App 一样,代码需要跟踪一个 SDL::Event 资源对象以访问事件队列。此外,我需要跟踪我将用于处理每种事件类型的例程。这是一种新的数据类型,因此我为引擎对象添加了一个新的分支以进行各种查找表。为了设置这两者,我添加了一个新的初始化函数

sub init_event_processing
{
    my $self = shift;

    $self->{resource}{sdl_event} = SDL::Event->new;
    $self->{lookup}{event_processor} = {
        &SDL_QUIT    => \&process_quit,
    };
}

SDL 事件类型遵循通用的 SDL 常量约定(大写字母,以 SDL_ 标记开头)。退出事件的类型是 SDL_QUIT,我将其与 process_quit 例程相关联,使用子例程引用。

init 的末尾添加的新行调用初始化例程

$self->init_event_processing;

每次通过时,主循环应先处理事件,然后更新视图(在我添加键盘控制后,视图应使用最新的用户输入进行更新)。现在 main_loop 中的循环内容如下

$self->{state}{frame}++;
$self->update_time;
$self->do_events;
$self->update_view;
$self->do_frame;

do_events 在这个阶段非常简单,只是调用 process_events 来处理挂起的 SDL 事件

sub do_events
{
    my $self = shift;

    my $queue = $self->process_events;
}

事件处理循环

process_events 是所有魔法发生的地方

sub process_events
{
    my $self = shift;

    my $event  = $self->{resource}{sdl_event};
    my $lookup = $self->{lookup}{event_processor};
    my ($process, $command, @queue);

    $event->pump;
    while (not $self->{state}{done} and $event->poll) {
        $process = $lookup->{$event->type} or next;
        $command = $self->$process($event);
        push @queue, $command if $command;
    }

    return \@queue;
}

前几行提供了先前存储的 SDL::Event 对象和事件处理器查找表的简短名称。其余变量分别存储

  • 当前事件的处理器例程的引用
  • 将事件转换为的内部命令
  • 从入站事件收集的命令队列

代码的核心首先告诉SDL::Event对象使用pump方法收集任何挂起的操作系统事件,为处理循环做准备。处理循环会检查是否有一个先前的事件已标记了done状态,这有助于提高对退出事件的响应性。假设没有发生这种情况,循环会使用SDL::Event::poll请求下一个SDL事件。poll在没有准备好拾取的事件时返回一个假值,从而退出循环。

循环内的第一行使用事件类型查找适当的事件处理例程。如果没有找到,我使用next再次循环并检查下一个事件。否则,下一行会以动态选择的方法调用处理例程来处理事件。如果处理例程确定事件需要额外的工作,它应该返回一个要队列化的命令包。如果事件应该被忽略,处理器只需返回一个假值。

循环的最后一行将(如果有的话)命令包添加到等待进一步处理的队列中。一旦循环处理了所有可用的SDL事件,process_events就会返回队列,以便do_events执行下一个处理阶段。

看起来可能有些混乱,因为每次循环代码都会重新使用相同的$event。你可能会期望SDL::Event::poll返回下一个等待的事件(以及没有事件剩余时的undef)。相反,SDL API指定poll将事件队列中下一个条目的数据复制到事件对象中,并返回一个真或假的值,表示此操作是否成功。就像OpenGL的一些怪癖一样,SDL_Perl直接复制了这个奇特的接口,简化了熟悉C API的程序员的过渡。

这个接口决策的后果是,事件处理例程必须复制SDL事件对象中需要用于以后的所有数据。下一次处理循环中的SDL::Event::poll调用将覆盖SDL事件对象中留下的任何数据,因此仅仅存储对象引用是不行的。

process_quit例程不需要保存任何数据;重要的是发生了SDL_QUIT事件。

sub process_quit
{
    my $self = shift;

    $self->{state}{done} = 1;
    return 'quit';
}

process_quit首先设置done状态标志,这将导致process_events中的循环提前退出,更重要的是,退出main_loop。它返回最简单的命令包类型,即表示quit命令的字符串。此时,没有代码进一步处理此命令,但这与下一个要展示的键盘版本保持一致。

这一切给我们带来了什么?首先,现在我们(终于)可以在动画运行完毕之前使用窗口管理器退出程序。在我的系统上,这意味着点击窗口标题栏上的‘X’。尽管如此,这并不等同于有退出键(我觉得这要方便得多)。

按键绑定

为了添加退出键,我首先需要决定哪个键应该退出程序。我会选择Esc键,因为这对我来说有记忆意义,但每个人都有自己偏好的键,所以我会允许这是一个配置设置。为此,我扩展配置哈希表,添加一个新的bind部分

sub init_conf
{
    my $self = shift;

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

现在,任何想要选择不同退出键的人都可以简单地更改键盘绑定哈希表。实际上,几个键可以关联到同一个命令,这样无论是Esc键还是‘q’都可以退出程序。每个指定键的哈希值是当用户按下该键时发出的命令包。这个与之前为窗口管理器退出消息选择的命令包相匹配。

接下来,我需要处理按键事件,其事件类型为SDL_KEYDOWN。我在event_processor哈希表中添加另一个条目

sub init_event_processing
{
    my $self = shift;

    $self->{resource}{sdl_event} = SDL::Event->new;
    $self->{lookup}{event_processor} = {
        &SDL_QUIT    => \&process_quit,
        &SDL_KEYDOWN => \&process_key,
    };
}

并定义按键处理器如下

sub process_key
{
    my $self = shift;

    my $event   = shift;
    my $symbol  = $event->key_sym;
    my $name    = SDL::GetKeyName($symbol);
    my $command = $self->{conf}{bind}{$name} || '';

    return $command;
}

process_key 首先从 SDL 事件中提取键符号。对于我们的目的而言,键符号相当晦涩难懂,因此我使用 SDL::GetKeyName 来请求与提取的键符号匹配的键名。这会产生一个友好的键名,我会在键绑定哈希表中查找以找到适当的命令包。如果没有,无关紧要;那个键还没有绑定,因此它会产生一个空的命令包。process_key 然后返回命令包,以便将其添加到队列中进行进一步处理。

处理命令包

到目前为止,代码将 Esc 键的按下转换为一个退出命令包,但 do_events 忽略了该包,因为它不处理从 process_events 收到的命令队列。为了使某些事情发生,我首先需要将每个已知命令与一个动作例程相关联。我在 init_command_actions 中创建一个新的查找哈希表来执行这种关联

sub init_command_actions
{
    my $self = shift;

    $self->{lookup}{command_action} = {
        quit      => \&action_quit,
    };
}

像往常一样,我在 init 的末尾调用这个函数

$self->init_command_actions;

现在是时候填充 do_events

sub do_events
{
    my $self   = shift;

    my $queue  = $self->process_events;
    my $lookup = $self->{lookup}{command_action};
    my ($command, $action);

    while (not $self->{state}{done} and @$queue) {
        $command = shift @$queue;
        $action  = $lookup->{$command} or next;
        $self->$action($command);
    }
}

在形式上与 process_events 类似。它不是将来自 SDL 内部队列的事件处理成命令包队列,而是将队列中的命令包处理成要执行的操作。循环像往常一样从检查 done 是否为真以及队列中是否还有待处理的命令开始。

在循环内部,它会从队列的前端移除下一个命令。下一行确定与该命令关联的动作例程。如果找不到,它使用 next 跳到下一个命令。否则,它将命令包作为参数调用动作例程作为一个动态选择的方法。这允许单个动作例程处理多个相似的命令,同时仍然能够区分它们。我稍后需要这个来处理移动键。

对于所有这些,action_quit 非常简单;它只是设置 done

sub action_quit
{
    my $self = shift;

    $self->{state}{done} = 1;
}

现在,Esc 键真的会提前退出程序,而窗口管理器的退出仍然有效。

现在,用户可以在任何时候退出,我最终可以移除 draw_frame 中的不协调部分。不再需要强制程序在五秒后结束,并且每帧打印的点已经失去了它们的有用性。该例程现在看起来像这样

sub draw_frame
{
    my $self = shift;

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

现在,如果你在右侧对象消失后等待足够长的时间,视图将旋转一周,场景将再次出现在左侧。这个例程版本要干净得多,并且意外地免费解决了下一个重构问题(在绘图例程中更改引擎状态)。

控制视图

现在代码可以处理按键事件,是时候使用键盘控制视图了。

我不希望视图在每一帧都完全重新计算,而希望每个按键修改现有的视图状态。为了指定初始状态,我添加了另一个初始化例程

sub init_view
{
    my $self = shift;

    $self->{world}{view} = {
        position    => [6, 2, 10],
        orientation => [0, 0, 1, 0],
        d_yaw       => 0,
    };
}

新条目 d_yaw 告诉 update_view 是否有面向变化(即增量,因此前导 d_)的挂起。到目前为止,代码只能处理偏航(左右旋转),因此这是现在需要的唯一增量键。

init 在其新的最后一行像往常一样调用这个例程

$self->init_view;

update_view 将偏航增量应用到视图方向,然后清除 d_yaw,这样它就不会在随后的帧中继续影响旋转(除非用户再次按下旋转键)

sub update_view
{
    my $self   = shift;

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

    $view->{orientation}[0] += $view->{d_yaw};
    $view->{d_yaw}           = 0;
}

分配给 yaw_leftyaw_right 命令的命令动作更新 d_yaw

sub init_command_actions
{
    my $self = shift;

    $self->{lookup}{command_action} = {
        quit      => \&action_quit,
        yaw_left  => \&action_move,
        yaw_right => \&action_move,
    };
}

为了分配这些命令的键,我在 init_conf 中更新 bind 哈希表

bind   => {
    escape => 'quit',
    left   => 'yaw_left',
    right  => 'yaw_right',
}

最大的变化是新的命令动作例程 action_move

sub action_move
{
    my $self = shift;

    my $command     = shift;
    my $view        = $self->{world}{view};
    my $speed_yaw   = 10;
    my %move_update = (
        yaw_left  => [d_yaw =>  $speed_yaw],
        yaw_right => [d_yaw => -$speed_yaw],
    );
    my $update = $move_update{$command} or return;

    $view->{$update->[0]} += $update->[1];
}

action_move 首先获取命令参数和当前视图。然后设置基本的旋转速度,以每按键一次的度数来衡量。接下来,%move_update 哈希定义了与每个已知命令关联的视图更新。如果它知道该命令,则检索相应的更新。如果不认识该命令,action_move 将返回。

最后一行解释了更新。更新数组的第一元素指定的视图键增加第二元素指定的数量。换句话说,接收到 yaw_left 命令会使程序将 $speed_yaw 添加到 $view->d_yaw;一个 yaw_right 命令将 -$speed_yaw 添加到 $view->d_yaw,实际上将视图转向相反方向。

有了这些更改,程序启动时直接查看场景,就像这篇文章开头附近出现的场景一样。按左箭头键或右箭头键会根据适当的方向旋转视图十度(记住,场景看起来是围绕视图反方向旋转的)。按住键没有任何作用;只有从未按到按下的变化才起作用,并且它只旋转视图一次。正如他们所说,这是次优的。

角速度

为了解决这个问题,代码必须从仅在角 位置 方面工作变为在角 速度 方面工作。按下一个键应该使视图以恒定速度旋转,并且它应该保持这种状态,直到键被释放。

速度与时间息息相关。特别是,对于每一帧,update_view 需要知道自上一帧以来过去了多少时间,以确定匹配旋转速度的角度变化。为了计算这个时间差,首先要确保代码在程序开始时就有有效的时间世界

sub init_time
{
    my $self             = shift;

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

当然,这需要在 init 的末尾添加另一行

$self->init_time;

有了这个,我可以将 update_time 改为记录每一帧的时间差

sub update_time
{
    my $self = shift;

    my $now  = now();

    $self->{world}{d_time} = $now - $self->{world}{time};
    $self->{world}{time}   = $now;
}

我已经做了一些不应该影响程序行为的更改,接下来我将做出几个肯定会改变行为的更改,所以现在是快速进行合理性测试的好时机。一切正常,现在是考虑剩余代码设计的时候了。

继续命令

实际上有两类键盘命令我想处理

  • 单次命令,如 quitdrop_objectpull_pin
  • 持续命令,如 yaw_leftscream_head_off

为了区分它们,我借鉴了一个现有的游戏约定,并使用前导 + 来表示持续命令。这改变了 init_conf 中的绑定映射

bind   => {
    escape => 'quit',
    left   => '+yaw_left',
    right  => '+yaw_right',
}

以及 command_action 查找

sub init_command_actions
{
    my $self = shift;

    $self->{lookup}{command_action} = {
          quit       => \&action_quit,
        '+yaw_left'  => \&action_move,
        '+yaw_right' => \&action_move,
    };
}

为了处理按键释放事件,我需要为 SDL_KEYUP 事件分配事件处理器。我将重用现有的 process_key 例程

sub init_event_processing
{
    my $self = shift;

    $self->{resource}{sdl_event} = SDL::Event->new;
    $self->{lookup}{event_processor} = {
        &SDL_QUIT    => \&process_quit,
        &SDL_KEYUP   => \&process_key,
        &SDL_KEYDOWN => \&process_key,
    };
}

process_key 需要接受训练,以便能够区分这两种类型的事件

sub process_key
{
    my $self    = shift;

    my $event   = shift;
    my $symbol  = $event->key_sym;
    my $name    = SDL::GetKeyName($symbol);
    my $command = $self->{conf}{bind}{$name} || '';
    my $down    = $event->type == SDL_KEYDOWN;

    if ($command =~ /^\+/) {
        return [$command, $down];
    }
    else {
        return $down ? $command : '';
    }
}

新代码(my $command 行之后的全部内容)首先将 $down 设置为 true,如果键被按下,或者设置为 false,如果键被释放。其余的更改替换了旧的 return $command 行。对于持续命令(以 + 开头的命令),有一个新的命令包类,包含 $command$down 布尔值,以指示命令应该开始还是结束。单次命令(没有前导 + 的命令),只在按键时发送简单的命令包;它们忽略按键释放。

为了处理新的命令包类,我也更新了 do_events

sub do_events
{
    my $self   = shift;

    my $queue  = $self->process_events;
    my $lookup = $self->{lookup}{command_action};
    my ($command, $action);

    while (not $self->{state}{done} and @$queue) {
        my @args;
        $command          = shift @$queue;
        ($command, @args) = @$command if ref $command;

        $action = $lookup->{$command} or next;
        $self->$action($command, @args);
    }
}

唯一的新的代码都在循环内部。它首先假设命令包是一个简单的包,没有参数。如果命令实际上是一个引用而不是字符串,它会将其解包成一个命令字符串和一些参数。$action 查找保持不变,但最后一行略有变化,将 @args 添加到动作例程的参数中。如果没有参数,这没有影响,所以单次动作例程如 action_quit 可以保持不变。

查看,遇见速度

视图需要跟踪用户按下或释放键时当前偏航速度和速度变化;我在 init_view 中将它们初始化为 0

sub init_view
{
    my $self = shift;

    $self->{world}{view} = {
        position    => [6, 2, 10],
        orientation => [0, 0, 1, 0],
        d_yaw       => 0,
        v_yaw       => 0,
        dv_yaw      => 0,
    };
}

update_view 需要几行代码来处理新变量

sub update_view
{
    my $self   = shift;

    my $view   = $self->{world}{view};
    my $d_time = $self->{world}{d_time};

    $view->{orientation}[0] += $view->{d_yaw};
    $view->{d_yaw}           = 0;

    $view->{v_yaw}          += $view->{dv_yaw};
    $view->{dv_yaw}          = 0;
    $view->{orientation}[0] += $view->{v_yaw} * $d_time;
}

在将任何速度变化添加到当前偏航速度之后,此方法将总偏航速度乘以该帧的时间变化,以确定方向变化。这将累积到当前方向和该帧的其他任何面向变化。

最后,我更新了 action_move 来处理新的语义

sub action_move
{
    my $self = shift;

    my ($command, $down) = @_;
    my $sign             = $down ? 1 : -1;
    my $view             = $self->{world}{view};
    my $speed_yaw        = 36;
    my %move_update      = (
        '+yaw_left'  => [dv_yaw =>  $speed_yaw],
        '+yaw_right' => [dv_yaw => -$speed_yaw],
    );
    my $update = $move_update{$command} or return;

    $view->{$update->[0]} += $update->[1] * $sign;
}

$sign 变量将 $down 参数从 1/0 转换为 +1/-1。我更改了例程的最后一行,在更新值之前将增量乘以这个符号。添加一个负值等同于减去原始值;这意味着按下键需要添加更新,而释放它将减去它。

为了确保新的偏航命令更新速度,我还修复了 %move_update 哈希,使其更新 dv_yaw 而不是 d_yaw,并使用命令名称的 + 版本。最后,为了恢复旧的旋转速率,我将 $speed_yaw 设置为每秒 36 度。

这个版本以大多数人期望的方式响应。按住键直到键被释放,直到这时才会转动正确的方向。当用户同时按下多个键时怎么办?这就是为什么我总是小心地使用 += 而不是老式的 = 来累积更新和增量。如果用户同时按住左右箭头键,视图将保持静止,因为他们已经向 dv_yaw 添加了相等且相反的值。如果用户只释放其中一个键,视图将按照仍然按下的键的方向旋转,因为相反的更新现在已经被减回。当仍然按住另一个键时按下释放的键,旋转再次停止,正如预期的那样。

当然,偏航速度左右必须相同并没有要求。事实上,对于飞机或宇宙飞船模拟,游戏引擎可能会设置不同的值来表示控制面或机动推进器的损坏。这甚至可能是游戏玩法的一部分,即同时按住方向键来部分补偿这种损坏,例如,在按住另一个键的同时轻触一个键。

有一件事不能神奇地工作,那就是确保如果多个键映射到相同的命令,同时按下它们不会使命令多次生效。目前,用户可以将五个键映射到同一个移动命令,并且移动速度是五倍。你可以尝试自己解决这个问题作为快速难题;我将在下一部分尝试解决这个问题。

后脑勺的眼睛

你可能好奇为什么我让 d_yaw 挂在那里,因为它现在不再使用了。我可以在上述提到的空间模拟中使用它来模拟一个卡住的推进器——持续尝试将船驶离航线。在一款第一人称游戏中,它允许我最喜欢的命令之一,+look_behind。按住适当的键将视图旋转 180 度。释放键将视图快速拉回。为了实现这一点,我需要向 bind 哈希中添加另一个条目

bind   => {
    escape => 'quit',
    left   => '+yaw_left',
    right  => '+yaw_right',
    tab    => '+look_behind',
}

然后是另一个 command_action 条目

sub init_command_actions
{
    my $self = shift;

    $self->{lookup}{command_action} = {
          quit         => \&action_quit,
        '+yaw_left'    => \&action_move,
        '+yaw_right'   => \&action_move,
        '+look_behind' => \&action_move,
    };
}

最后但并非最不重要的是,%move_update 中的另一个条目

my %move_update      = (
    '+yaw_left'    => [dv_yaw =>  $speed_yaw],
    '+yaw_right'   => [dv_yaw => -$speed_yaw],
    '+look_behind' => [d_yaw  =>  180       ],
);

这就是全部了:总共三行,它们都是查找哈希表的条目。

结论

这就是这篇文章的全部内容;它已经够长了。我继续从上一篇文章结束的地方开始。从那里,我谈到了视图的翻译和旋转;毫秒级分辨率的SDL时间;从生硬的开始到平滑的运动;基本的SDL事件和键盘处理;单次和持续命令;以及大量的重构。

下次,我将讨论移动视点的位置,清理draw_view,并在OpenGL方面花更多时间,讨论光照和材料的基本知识。同时,我在这篇文章中已经涵盖了很多内容,所以请大胆尝试!

标签

反馈

这篇文章有什么问题吗?请帮助我们通过在GitHub上打开一个issue或pull request。