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

本文是该系列的第3篇,旨在使用Perl构建一个完整的3D引擎。第一篇文章从基本程序结构开始,逐步展示了如何在OpenGL窗口中显示一个简单的深度缓冲场景。第二篇文章继续讨论了时间、视图动画、SDL事件、键盘处理,并对代码进行了大量的重构。

编者按:请参阅本系列的下一篇文章,分析您的应用程序

在本篇文章的后面,我将讨论视图位置的移动,继续通过清理draw_view来重构工作,并开始使用OpenGL光照和材质来改进场景的外观。在涉及这一点之前,对前几篇文章的反馈中包括了一些常见的请求:截图和移植问题帮助。如果您在运行SDL_Perl以及这些文章中的示例代码时遇到问题,或者可能帮助Mac OS X和Win32的读者,请查看下一部分。否则,请跳转到截图部分,那里是主要文章的开始。

已知的移植问题

通用

SDL_Perl的一些版本需要程序加载SDL::Constants以识别SDL_QUIT和其他常量。由于此更改对其他用户应该是透明的,因此我已经将其合并到最新的示例代码中,回溯到首次使用SDL常量的情况。

FreeBSD

请参阅第二篇文章开头的建议。

Mac OS X

我在Mac OS X的移植问题上花了一些时间进行研究,但至今还没有找出一个简单的步骤来从头开始构建SDL_Perl。最近在sdl-devel邮件列表上的邮件似乎表明,目前对最近的SDL_Perl源代码的Mac OS X构建有问题,而旧版本似乎更糟。过去有一些包装尝试,但我迄今为止还没有找到能够将完全配置的SDL_Perl库安装到系统perl中的。我不是Mac移植专家,因此我感谢任何帮助;如果您有任何建议或解决方案,请在本月的文章讨论中发表评论。

Slackware

根据Federico(ironfede)在上个月的文章讨论中的评论,Slackware附带了一个需要SDL::Constants的SDL_Perl版本。这并不是当前示例代码的问题,正如我在通用问题段落中提到的,我已经修复了这个问题。

Win32

Win32移植过程与Mac OS X移植过程相同。当chromatic向我指出一些旧的Win32 PPM包时,我感到非常兴奋,但遗憾的是,它们不包括SDL::OpenGL的运行版本。由于我没有访问Microsoft编译器,并且在使用Win32下的gcc方面经验很少,手动构建最多是“有趣的”。与Mac用户一样,我感谢读者的任何帮助。如果您有任何建议或解决方案,请在本月的文章讨论中发表评论。

截图

幸运的是,截图处理比端口问题要简单得多。我希望用户能够随时进行截图。实现这一目标的明显方法是将截图动作绑定到键盘上;我随机选择了功能键F4。首先,我将它添加到了bind哈希表中。

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

新的键必须有一个动作流程,因此我也修改了查找哈希表。

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

我需要等待整个场景的绘制完成,才能进行快照,但事件处理是在绘制开始之前发生的。为了解决这个问题,我设置了一个状态变量,标记用户已请求截图,而不是立即进行截图。

sub action_screenshot
{
    my $self = shift;

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

代码在end_frame的末尾检查这个状态变量,在绘制完成后,并且已经将屏幕与OpenGL的颜色缓冲区中的图像同步。

sub end_frame
{
    my $self = shift;

    $self->{resource}{sdl_app}->sync;
    $self->screenshot if $self->{state}{need_screenshot};
}

screenshot例程出奇地简短但紧凑。

sub screenshot
{
    my $self = shift;

    my $file = 'screenshot.bmp';
    my $w    = $self->{conf}{width};
    my $h    = $self->{conf}{height};

    glReadBuffer(GL_FRONT);
    my $data = glReadPixels(0, 0, $w, $h, GL_BGR,
                            GL_UNSIGNED_BYTE);
    SDL::OpenGL::SaveBMP($file, $w, $h, 24, $data);

    $self->{state}{need_screenshot} = 0;
}

例程首先指定截图的文件名,并收集屏幕的宽度和高度。真正的工作从调用glReadBuffer开始。根据OpenGL驱动程序、硬件和许多高级设置,OpenGL可能提供了多个颜色缓冲区,可以用于绘制和读取图像。事实上,大多数系统的默认行为是在一个称为后缓冲区的缓冲区上绘制,并显示一个单独的缓冲区,称为前缓冲区。在完成每帧的绘制后,SDL::App::sync调用将图像从后缓冲区移动到前缓冲区,以便用户可以看到它。在幕后,OpenGL通常根据底层实现以两种不同的方式处理这个问题。软件OpenGL实现,如Mesa,将数据从后缓冲区复制到前缓冲区。硬件加速系统可以交换内部指针,使得后缓冲区变为前缓冲区,反之亦然。如你所想,这要快得多。

这项额外的工作带来了巨大的好处。在没有双缓冲的情况下,一旦一帧完成,下一帧就会立即将屏幕清空为黑色,并从头开始绘制。根据用户的显示器与应用程序的相对速度差异,这可能会让用户看到闪烁的、暗的、永远半绘制的场景。有了双缓冲,这个问题几乎消失了。前缓冲区显示一个稳定的图像,而所有的绘制都在后缓冲区上进行。一旦绘制完成,最多只需几毫秒即可同步并开始显示新帧。对人类眼睛来说,动画看起来是稳定的、明亮的(希望)且平滑的。

在这种情况下,我想要确保拍摄的截图与用户看到的图像完全相同,所以我告诉OpenGL我想要读取前缓冲区中的图像(GL_FRONT)。

到目前为止,可以安全地将图像数据以适当的格式读入Perl缓冲区。glReadPixels的第一个四个参数指定要读取的子图像的左下角和大小。接下来的两个参数共同告诉OpenGL我想要的数据格式。我指定我想读取整个窗口,并且我希望数据以适合BMP文件的格式读取——每个像素的红色、绿色和蓝色颜色通道都有一个无符号字节,但顺序相反。

一旦我从OpenGL获得数据,我就使用SDL_Perl实用程序例程SaveBMP将图像保存到文件。参数是文件名、图像宽度、图像高度、颜色深度(每像素24位)和数据缓冲区。最后,例程重置need_screenshot状态标志并返回。

此时,你应该能够每次按 F4 键时截取一次屏幕。当然,我希望在本文中随着代码的进展展示几个屏幕截图。当前的代码每次请求新的截图时都会覆盖之前的截图文件。因为我给每个可运行的代码版本编号,所以我使用了一个快速的解决方案,为每个代码步骤生成不同的截图文件名。我首先加载了一个核心 Perl 模块来从路径中删除目录

use File::Basename;

然后我使用脚本本身的文件名作为截图文件名的一部分

    my $file = basename($0) . '.bmp';

这可能已经足够你的应用程序使用,或者你可能想要添加一些代码来为每个文件唯一编号。这段代码足以解决我的问题,所以我将更强大的版本留给读者作为练习。

下面是这个第一个截图

细心的读者会注意到这个图像不是 BMP 文件;它是一个 PNG 图像,它比 BMP 小得多,并且更适合网页标准。有许多工具可以进行这种转换。任何好的图像编辑器都可以做到。在这种情况下,这是过度杀鸡用牛刀——我反而使用了来自 ImageMagick 工具套件的 convert 程序

convert step042.bmp step042.png

移动视点

那个视图有点过于夸张。用户甚至无法移动视点来查看场景的背面或侧面。现在是时候改变了。我开始定义一些新的快捷键

        bind   => {
            escape => 'quit',
            f4     => 'screenshot',
            a      => '+move_left',
            d      => '+move_right',
            w      => '+move_forward',
            s      => '+move_back',
            left   => '+yaw_left',
            right  => '+yaw_right',
            tab    => '+look_behind',
        }

然后我更新了 command_actionlookup 哈希表来处理这些作为移动键

    $self->{lookup}{command_action} = {
          quit          => \&action_quit,
          screenshot    => \&action_screenshot,
        '+move_left'    => \&action_move,
        '+move_right'   => \&action_move,
        '+move_forward' => \&action_move,
        '+move_back'    => \&action_move,
        '+yaw_left'     => \&action_move,
        '+yaw_right'    => \&action_move,
        '+look_behind'  => \&action_move,
    };

init_view 需要初始化两个额外的速度组件及其匹配的增量

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

action_move 需要一个与现有偏航速度匹配的新移动速度,以及向 %move_update 添加一些内容

    my $speed_move       = 5;
    my %move_update      = (
        '+yaw_left'     => [dv_yaw     =>  $speed_yaw ],
        '+yaw_right'    => [dv_yaw     => -$speed_yaw ],
        '+move_right'   => [dv_right   =>  $speed_move],
        '+move_left'    => [dv_right   => -$speed_move],
        '+move_forward' => [dv_forward =>  $speed_move],
        '+move_back'    => [dv_forward => -$speed_move],
        '+look_behind'  => [d_yaw      =>  180        ],
    );

到目前为止,更改主要是对哈希的更新,而不是过程性代码;这是一个好兆头,表明现有的代码设计还有更多生命力。当概念上简单的更改需要重大的代码修改,特别是特殊情况或重复的代码块时,是时候寻找重构的机会了。幸运的是,这些更改是在初始化和配置中而不是在特殊情况下。

需要大量新代码的一个例程是 update_view。我添加了以下这些行到末尾

    $view->{v_right}        += $view->{dv_right};
    $view->{dv_right}        = 0;
    $view->{v_forward}      += $view->{dv_forward};
    $view->{dv_forward}      = 0;

    my $vx                   =  $view->{v_right};
    my $vz                   = -$view->{v_forward};
    $view->{position}[0]    += $vx * $d_time;
    $view->{position}[2]    += $vz * $d_time;

这个例程开始看起来有点重复,有多个非常相似的代码行的副本,所以它被列入未来重构的清单。还没有足够的情况让最佳解决方案明显,所以我将稍后再考虑。

新代码首先以与之前在例程中更新 v_yaw 一样的方式应用新的速度增量。它通过注意到视图开始时“前进”平行于负 Z 轴,“右侧”平行于正 X 轴,将右侧和前进速度转换为沿世界轴线的速度。然后它将 X 和 Z 速度乘以时间增量以到达位置变化,并将其添加到当前视图位置。

只要用户不旋转视图,这个版本的代码就可以正常工作。当视图旋转时,“前进”和“右侧”不匹配新的视图方向。它们仍然指向 -Z 和 +X 轴,这在高旋转时可能会非常令人困惑。解决方案是进行一些三角运算。想法是将初始 X 和 Z 速度视为总速度向量的分量,并将该向量旋转与用户旋转视图相同的角度

    my $vx                   =  $view->{v_right};
    my $vz                   = -$view->{v_forward};
    my $angle                = $view->{orientation}[0];
    ($vx, $vz)               = rotate_xz($angle, $vx, $vz);
    $view->{position}[0]    += $vx * $d_time;
    $view->{position}[2]    += $vz * $d_time;

中间的两行是新的。它们调用 rotate_xz 进行向量旋转工作,然后将 $vx$vz 设置为旋转速度向量的返回分量。 rotate_xz

sub rotate_xz
{
    my ($angle, $x, $z) = @_;

    my $radians = $angle * PI / 180;
    my $cos     = cos($radians);
    my $sin     = sin($radians);
    my $rot_x   =  $cos * $x + $sin * $z;
    my $rot_z   = -$sin * $x + $cos * $z;

    return ($rot_x, $rot_z);
}

将角度从度转换为弧度后,代码计算并保存该角度的正弦和余弦值。然后它根据原始未旋转的分量计算旋转后的速度分量。最后,它将旋转后的分量返回给调用者。

这里我将跳过推导过程(欢迎之至),但如果你对这个计算是如何以及为什么进行旋转感到好奇,有许多书籍会以惊人的细节解释向量数学的奇妙之处。David M. Bourg的《游戏开发者物理学》(O’Reilly出版,链接:http://www.oreilly.com/catalog/physicsgame/)对旋转进行了高级讨论。Eric Lengyel的《3D游戏编程与计算机图形学数学》(Charles River Media出版,链接:http://www.charlesriver.com/titles/lengyelmath2.html)包含更深入的讨论,不过每次读它我都会回想起大学里的数学课。说到这个,任何线性代数的大学教科书都应该包含你想要的详细程度。

此代码需要定义PI,由以下行提供,位于程序顶部附近,在请求Perl警告之后:

use constant PI => 4 * atan2(1, 1);

constant模块在编译阶段评估可能的复杂计算,然后在运行时将它们转换为常量。上述计算利用了一个标准的三角恒等式,以系统可以提供的最大位数推导出PI的值。

update_view现在无论视图朝向哪个角度都能正确执行。找到更有趣的视图并不需要很长时间。

光明降临!

好吧,这或许并没有什么更有趣,我承认。这个场景需要一个稍微有点氛围的灯光,而不是我之前用的那种平坦的颜色(尤其是因为它们使得难以清楚地看到每个物体的形状)。作为一个第一步,我在prep_frame的末尾添加了一行新代码来开启OpenGL的灯光系统。

    glEnable(GL_LIGHTING);

远非照亮场景,现在视图几乎变成了黑色。如果你非常仔细地看,并且你的显示器和房间灯光足够宽容,你应该能勉强辨认出物体,它们在黑色背景上是深灰色。为了看清楚任何东西,我必须同时开启GL_LIGHTING和一盏或多盏灯为场景提供光线。没有灯光,物体将保持深灰色而不是真正的黑色,因为OpenGL默认情况下会对整个场景应用一个非常小的光线量,这被称为环境光。为了使物体更亮,我在prep_frame的末尾又添加了一行新代码来开启第一盏OpenGL灯。

    glEnable(GL_LIGHT0);

现在物体变亮了,但它们仍然是灰色。当启用灯光计算颜色时,OpenGL使用与禁用灯光时完全不同的参数集。这些新参数组成了一个材质。组成材质的参数之间的复杂相互作用可以产生非常有趣的颜色效果,但在这个案例中,我并不想创建复杂的效果。我只想让我的物体恢复到原来的颜色,而不用担心材质提供的全部复杂性。幸运的是,OpenGL提供了一种方法来声明当前材质应默认为当前颜色。为了做到这一点,我在prep_frame的末尾又添加了一行代码。

    glEnable(GL_COLOR_MATERIAL);

此时,物体再次有了颜色,但每个面的颜色仍然是相同的,而不是看起来像是由某个地方的单个光源照亮的。问题是OpenGL不知道每个面是朝向还是背离光源,以及如果朝向光源,程度如何。面与光源之间的角度决定了落在表面上的光线量,因此也决定了表面应该看起来有多亮。虽然可以从顶点的位置计算出场景中每个面的角度,但这并不总是正确的事情(尤其是在处理曲面时),因此OpenGL不会内部计算这个。相反,程序需要做方向计算,并将结果告诉OpenGL,这个结果被称为法向量

幸运的是,在draw_cube中,面与坐标轴对齐,因此每个面都指向其中一个轴(正或负X、Y或Z)。在这里我不需要进行任何计算,只需告诉OpenGL每个面关联哪个法线向量。

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]);

    glBegin(GL_QUADS);

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

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

新行是@normals数组的定义,以及位于$face循环顶部两行代码,用于选择每个面的正确法线,并通过glNormal将其传递给OpenGL。

盒子现在着色合理,很明显光线来自观众后面的某个地方;前面比侧面亮。不幸的是,现在轴再次变暗。

我没有为轴线指定任何法线,因为这对于线条或点来说没有太多意义。然而,启用光照后,OpenGL需要为每个光照对象设置一组法线,因此它回退到当前状态并使用最近定义的法线。对于第一帧来说,这是默认法线,它恰好指向默认的第一个光源,但对于后续帧来说,它将是draw_cube中设置的最后一个法线。后者肯定不指向光源,轴最终变暗。

我更希望轴线完全不参与光照计算,并保持其原始亮颜色,无论场景中的光照(或缺乏光照)如何。为此,我在prep_frame中移除了启用GL_LIGHTING的行,并在draw_view顶部附近插入了两行新代码。

sub draw_view
{
    glDisable(GL_LIGHTING);

    draw_axes();

    glEnable(GL_LIGHTING);

现在在绘制轴线之前关闭光照,并在之后重新开启。轴线再次有了亮颜色,但旋转视图暴露了一个新问题。当视图旋转时,光线的方向也发生变化。

由于OpenGL计算光位置和方向的方式,任何在设置视图之前定义的光都会像矿工头盔上的光源一样固定在观众处。要相对于模拟世界固定一个光,请在设置视图后定义光。我在prep_frame中移除了启用GL_LIGHT0的行,并将其移动到新程序set_world_lights中。

sub set_world_lights
{
    glEnable(GL_LIGHT0);
}

然后我更新了draw_frame,使其在设置视图后调用新程序。

sub draw_frame
{
    my $self = shift;

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

不幸的是,这不起作用。OpenGL仅在明确更改时才更新其内部状态,即当光的位置和方向发生变化时,而不是当启用或禁用光时。我从未明确设置过光的参数,所以原始默认值仍然有效。这个问题可以通过在set_world_lights中添加另一行代码来轻松解决。

sub set_world_lights
{
    glLight(GL_LIGHT0, GL_POSITION, 0.0, 0.0, 1.0, 0.0);

    glEnable(GL_LIGHT0);
}

在为数不多的OpenGL界面决策中,这一行设置了光的方向,而不是其位置。OpenGL定义所有灯光为两种类型之一:方向性位置性。OpenGL假设方向性灯光非常远,所以场景中的任何地方从光到每个对象的都是从同一个方向。位置性灯光较近,OpenGL必须独立地计算场景中每个对象的每个顶点从光到方向。正如你所想象的,这要慢得多,但会产生更有趣的照明效果。

选择这两种类型的关键是上述glLight调用中的最后一个参数。如果此参数为0,则光为方向性,其他三个坐标指定光线来自的方向。在这种情况下,我指定光线应来自+Z方向。如果最后一个参数为1,则OpenGL将光设置为位置性,并使用其他三个坐标在场景中设置光的位置。现在,我将跳过当使用除01以外的值时发生的事情的细节,但简而言之,光将是位置性,额外的计算确定实际使用的位置。大多数时候最好忽略这种情况。

您可能想知道为什么我明确指定了 0.01.0 而不是 01。这是针对某些版本的 SDL_Perl 中 glLight 的一个错误的一个解决方案,该错误发生在它接收到整数参数而不是浮点参数时。

添加这一行后,灯光现在在世界中固定不动,即使用户移动和旋转视图

灯笼

当然,有时连接到观看者的灯光正是所需的效果。例如,可能希望玩家手持灯笼或手电筒照亮黑暗的地方。这两个都是局部光源,可以相当程度地照亮附近的物体,但对远处物体的照亮则很少。它们的主要区别在于,手电筒和某些类型的灯笼主要向一个方向投射光线,通常呈圆锥形。大多数灯笼、火炬和类似的光源向所有方向投射光线(除把手、燃料罐等产生的阴影外)。

非定向光在实现上稍微简单一些,所以我先从灯笼光开始。我想让灯光固定在观看者的位置,因此我在设置视图之前定义了灯光

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

我将观看者固定的灯光称为 眼灯,因为 OpenGL 将其用于灯光的坐标系称为 眼坐标系,而以这种方式定义的灯光则保持特定的“相对于眼睛”的位置。以下是 set_eye_lights

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

    glEnable(GL_LIGHT1);
}

这里我以设置第一盏灯相同的方式设置了第二盏灯。请注意,实际上我在程序中定义第二盏灯的时间早于第一盏灯并不重要。每个 OpenGL 灯光都是独立编号的,并且始终保持相同的编号,而不是像堆栈或队列那样按使用顺序编号。

遗憾的是,新代码似乎没有任何效果。实际上,场景中确实有一个新的灯光在闪耀——与默认为发出明亮白光的 GL_LIGHT0 不同,所有其他灯光默认为黑色,并为场景提供不了任何新的光源。解决方案是设置灯光的另一个参数

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

每个物体的正面应该显得明显更亮。在场景中移动时,可以看到眼灯只使由世界光微微照亮的表面变亮

然而,如果您仔细观察,会发现光照随视图旋转而变化——而不是位置。我将灯光定义为方向性,光线来自观看者背后,而不是位置性,光线来自观看者直接。我在前面暗示了修复方法——按照以下方式更改 GL_POSITION 参数

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

现在灯光从眼坐标的 (0, 0, 0) 来,就在视点处。移动和旋转显示,这个版本具有预期的效果。

模拟的灯笼在远处的物体上发出的光与在近处的物体上发出的光一样亮。真实灯笼的光随与灯笼的距离增加而迅速减弱。OpenGL 可以通过另一个设置来实现这一点

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

这个案例告诉 OpenGL 在其方程中包含一个与灯光和物体之间距离成比例的衰减项。物理知识丰富的读者会指出,物理上准确的衰减与距离的平方成正比,OpenGL 允许使用 GL_QUADRATIC_ATTENUATION 来实现这一点。然而,包括 OpenGL 使用的光照方程和图形硬件、显示器以及人眼的非线性效应在内的众多因素使这种更准确的衰减看起来相当奇怪。线性衰减在许多情况下看起来更好,所以我这里使用了它。也可以结合不同的衰减类型,使近处的物体衰减呈线性,远处的物体呈二次方,这可能是一个更好的折衷方案。0.5 设置告诉 OpenGL 我场景中线性衰减效果的强度应该是多少。

在场景中移动时,你应该能看到相对细微的变暗效果。不必害怕让它保持微妙,而不是将变暗效果调得非常强烈。有些场景需要强烈的照明效果,而有些场景则需要观众仅在潜意识中注意到的照明效果。在某些可视化应用中,照明的细微之处是一种优点,可以让人类的视觉系统处理复杂的场景而不会感到不知所措。

手电筒

我真的很喜欢手电筒投射的光锥,所以我将灯笼的全向光照转换为有方向性的光锥。OpenGL将这种类型的灯光称为聚光灯,并包含几个光参数来定义它们。第一个变化是set_eye_lights中的新设置。

    glLight(GL_LIGHT1, GL_SPOT_CUTOFF, 15.0);

这设置了光束中心与光锥边缘之间的角度。OpenGL接受180度(全向)或0到90度之间的任何值(从激光束到半球形光照)。在这种情况下,我选择了中心线到边缘的角度为15度,形成一个30度宽的光锥。

这个变化确实限制了光锥,但也揭示了一个丑陋的伪影。移动到白色立方体的左前角前面,并旋转视图以平移光线经过黄色盒子。你会看到光线从角到角跳跃,甚至在中间完全消失。即使角落被照亮,光线的形状也不是很锥形。

OpenGL的标准光照模型仅在每个顶点执行光照计算,并在之间插值结果。对于具有许多小面和因此具有高顶点密度的模型,这相对较好。它会在包含具有大面和少量顶点的对象的场景中糟糕地崩溃,尤其是在位置光源靠近对象时。聚光灯使问题更加明显,因为它们可以轻松地在两个顶点之间发光,而不照亮任何一个;然后多边形看起来均匀地暗。

向Rush致敬

结合最新的硬件,高级OpenGL功能可以通过每像素光照计算来解决这个问题。较旧的硬件可以用光照贴图和类似技巧来模拟。与其使用高级功能,我将使用一种更简单的方法来改进照明,称为细分。(那些对Rush引用感到困惑的人现在可以集体松一口气了。)细分有其自身的问题,我稍后会展示,但这些问题解释了图形API设计中的很多内容,因此值得一看。

正如其名所示,基本思想是将每个面细分为许多更小的面,每个面都有其自己的顶点集。对于球体和圆柱体等曲线对象,这是必要的,以便附近的物体看起来可以平滑弯曲。对于像盒子和平面体这样具有大平面面的对象,这仅仅是迫使每个面进行多次每顶点光照计算的一个副作用。

在我可以使用细分面之前,我需要通过重构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);
    }
}

现在,draw_cube不再直接执行OpenGL调用,而是调用draw_quad_face。对于它创建的每个大面,它都会创建一个新的@corners数组,其中包含该面的角顶点坐标。然后它将那个数组和面法线传递给定义如下draw_quad_face

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

此函数执行与draw_cube相同的OpenGL操作。我还使用了与之前不同的参数传递方式。在这种情况下,我传递了命名参数,因为我知道我很快会添加至少一个额外的参数,并且很可能以后还会添加更多。当函数的参数可能随时间变化,尤其是当调用者可能只想指定少数几个参数并允许其余参数采用合理的默认值时,命名参数通常是更好的选择。参数可以是哈希引用或填充到哈希中的列表。这次,我选择了后一种方法。

重构之后是测试,快速运行显示一切按预期工作。有了这个信心,我将draw_quad_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;
    }
}

新例程首先添加了新的可选参数divisions,默认值为10。这指定了面在“向下”和“横向”上的细分数量;实际子面的数量是这个数字的平方。对于默认的10个细分,每个大面有100个子面,因此每个立方体有600个子面。

下一行以逆时针顺序标记顶点。这使得顶点A位于顶点C的对角线位置,B在一侧,D在另一侧。

如下一行的注释所示,我通过假设面至少是一个平行四边形,大大简化了数学计算。通过这种简化,我可以计算沿着AB和AD边的单次分割的步长,并使用这些步长在整个大面上定位每个子面。

我不能简单地计算步长作为一个简单的移动距离,因为我不知道每个边的指向,也不知道每个步骤该朝哪个方向移动。相反,我计算边两端顶点之间的向量差,并将其除以分割数。代码进行了两次相同的计算,所以我将其提取到了一个单独的例程中

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

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

回到draw_quad_face,它在$s_ab(AB边的步长)和$s_ad(AD边的步长)中存储向量步长。接下来,它设置当前法线,对于平面面,整个面都是相同的。

最后,我可以开始定义子面本身。我利用OpenGL的四边形带原语,将子面绘制成一系列从AB边延伸到CD边的平行条带。对于每个条带,我首先需要计算其起始顶点的位置。我知道它位于AB边上,因此代码从A开始,并为每个完成的条带添加一个AB步长。对于第一条带,起始顶点位于A。对于最后一条带,起始顶点将远离B一个步长(一个条带宽度)。它初始化当前顶点@v为起始顶点,并在沿每个条带移动时保持其更新。

然后,它开始一个四边形条带,使用glBegin(GL_QUAD_STRIP)。为了定义条带,我指定了沿其长度对面对面每对顶点的位置。对于每一对,它使用当前顶点和沿着AB方向进一步的一个计算顶点。然后,代码将当前顶点沿条带长度(AD方向)移动一个步长。一旦条带完成,它使用glEnd结束它,并循环进行下一条带。

所有这些复杂性都产生了相当大的视觉效果

很明显,光线有明显的形状,但光照如此刺眼,令人分心。一种修复方法是增加分割数,使子面更小。这需要在draw_cube中的draw_quad_face调用中添加一个简单的操作

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

结果是刺眼程度明显减少

不幸的是,锯齿虽然更小,但仍然非常明显——观看者离物体越近,它们看起来就越大。需要绘制的子面数量也增加了九倍(3010 平方),程序现在运行得相当慢。如果你很幸运地拥有一个配备快速视频硬件的最新系统,并且没有注意到速度下降,可以使用大约100次的分割次数。你可能会看到它。

边缘柔化

显然,仅仅增加细分次数并不能显著提高渲染效果,同时也会大大降低性能。我将尝试另一种方法,回到我对手电筒的了解。大多数手电筒发出的光束中心比边缘亮。有些在中心有一个暗圈,但我会忽略这一点。我可以利用这一点来创建更精确的图像,并且可以大大柔化大的锯齿。首先,我撤销了对draw_quad_face调用的更改

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

然后,我更改了set_eye_lights中手电筒的一个聚光参数,并添加了另一个

    glLight(GL_LIGHT1, GL_SPOT_CUTOFF,   30.0);
    glLight(GL_LIGHT1, GL_SPOT_EXPONENT, 80.0);

通过更改GL_SPOT_CUTOFF,我将光束宽度扩大到原来的两倍。同时,我告诉OpenGL使用GL_SPOT_EXPONENT使边缘暗淡很多,希望隐藏任何锯齿。新的参数有一个有点令人困惑的名字,它指的是决定非中心暗淡效果强度的方程的细节。在计算机图形学数学的整个主题中,暗淡是中心线与被照亮的顶点之间角度的余弦函数。实际上,暗淡系数是GL_SPOT_EXPONENT指定的指数的余弦。为什么使用角度的余弦?事实证明,计算余弦比计算角度本身便宜,并且还能产生很好的平滑效果。

幸运的是,新的光束在视觉上看起来与旧的光束宽度大致相同

足够好了。图像看起来更好,而且没有高细分级别带来的巨大性能压力。

重构绘图

仍然有一些不对劲的地方,但需要场景中增加一些物体才能显示出来。draw_view已经是重复硬编码的混乱,它已经列入“待重构”清单有一段时间了,所以现在似乎是一个在添加混乱之前清理它的好时机。

draw_view为每个绘制的对象执行一系列变换和状态设置。我希望转向一个更数据驱动的架构,其中模拟世界中的每个对象都由一个数据结构表示,该结构指定了所需的变换和设置。最终,这些结构可能成为完整的祝福对象,但我会从简单开始。

我在init_objects中初始化了数据结构

sub init_objects
{
    my $self = shift;

    my @objects = (
        {
            draw        => \&draw_axes,
        },
        {
            lit         => 1,
            color       => [ 1, 1,  1],
            position    => [12, 0, -4],
            scale       => [ 2, 2,  2],
            draw        => \&draw_cube,
        },
        {
            lit         => 1,
            color       => [ 1, 1, 0],
            position    => [ 4, 0, 0],
            orientation => [40, 0, 0, 1],
            scale       => [.2, 1, 2],
            draw        => \&draw_cube,
        },
    );

    $self->{world}{objects} = \@objects;
}

每个散列包括应用于它的各种变换的参数,以及一个指向实际绘制对象的例程的引用和一个标志,指示对象是否应受OpenGL照明的约束。然后,对象数组成为世界散列的一部分,以便于以后访问。

我像往常一样在init的末尾调用了这个例程

    $self->init_objects;

我还用解释数据结构为一系列OpenGL调用的版本替换了draw_view

sub draw_view
{
    my $self    = shift;

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

    foreach my $o (@$objects) {
        $o->{lit} ? glEnable (GL_LIGHTING)
                  : glDisable(GL_LIGHTING);

        glColor(@{$o->{color}})        if $o->{color};

        glPushMatrix;

        glTranslate(@{$o->{position}}) if $o->{position};
        glRotate(@{$o->{orientation}}) if $o->{orientation};
        glScale(@{$o->{scale}})        if $o->{scale};

        $o->{draw}->();

        glPopMatrix;
    }
}

新的例程遍历世界对象数组,执行每个请求的操作。它跳过或默认任何未指定的值。首先,是启用或禁用GL_LIGHTING的选择,然后如果请求,设置当前颜色。代码接下来检查并应用通常的变换,最后调用对象绘制例程。

为了简单和健壮,我无条件地将变换和绘图例程包裹在矩阵的压入/弹出对中,而不是尝试检测它们是否需要压入和弹出。OpenGL 实现通常高度优化,使用原生代码,我所做的任何检测都将是 Perl。这种“优化”可能会使事情变得更慢。这样,我的代码更加清晰,即使是不当的绘图例程,它在内部执行变换而没有清理,也不会影响下一个绘制的对象。

快速测试显示,重构后的版本仍然工作。现在我可以添加几个对象来演示剩余的照明问题。我在 init_objects 的末尾之前插入了一个新的循环来程序化地指定几个更多的盒子

    foreach my $num (1 .. 5) {
        my $scale =   $num * $num / 15;
        my $pos   = - $num * 2;
        push @objects, {
            lit         => 1,
            color       => [ 1, 1,  1],
            position    => [$pos, 2.5, 0],
            orientation => [30, 1, 0, 0],
            scale       => [1, 1, $scale],
            draw        => \&draw_cube,
        };
    }

    $self->{world}{objects} = \@objects;
}

对于每个盒子,只有两个参数会变化:位置和 Z 比例尺。我选择位置,将每个盒子放在最后一个旁边,沿着 -X 轴前进。比例尺设置为每个盒子的高度和宽度保持不变,但深度从第一个盒子的非常浅到最后的相当深。

循环指定了总共五个盒子,并首先计算当前盒子的 X 位置和 Z 比例尺(深度)。接下来的几行只是为新的盒子创建一个新的散列并将其推入对象数组。

最后,有一个最后的更改——明亮的全局光照压倒了手电筒引起的问题。这是一个简单的修复;我注释掉了启用它的那一行

sub set_world_lights
{
    glLight(GL_LIGHT0, GL_POSITION, 0.0, 0.0, 1.0, 0.0);

#     glEnable(GL_LIGHT0);
}

通过在场景中向左平移直到视点位于新盒子前面,问题变得明显

照明的亮度根据盒子的深度有很大变化!这种相当不直观的结果是 OpenGL 处理法线的方式的副作用。法线指定了与顶点关联的表面的方向。如果刚性对象旋转,其表面也会旋转,因此所有法线也必须旋转。OpenGL 通过像变换顶点坐标一样变换法线坐标来处理这个问题。这与其他变换(除了平移和旋转)会遇到麻烦。OpenGL 计算假设法线是归一化的(具有单位长度)。缩放法线破坏了这个假设,并导致了上述效果。

为了解决这个问题,我告诉 OpenGL 法线可能不具有单位长度,并且 OpenGL 必须在执行其他计算之前对它们进行归一化。这不是默认行为,因为归一化每个向量的性能成本。可以确保变换后法线始终具有单位长度的应用程序可以保持默认设置并运行得更快。我想允许对象的任意缩放,所以我在 prep_frame 的末尾添加了另一行来启用自动归一化

    glEnable(GL_NORMALIZE);

这样修复了问题

在杀死这个错误后,我通过取消注释 set_world_lights 中的 glEnable 行重新启用了全局光照

sub set_world_lights
{
    glLight(GL_LIGHT0, GL_POSITION, 0.0, 0.0, 1.0, 0.0);

    glEnable(GL_LIGHT0);
}

结论

在这篇文章中,我快速地移动,涵盖了截图、视点的移动、OpenGL 中照明的开始以及盒子的细分面。在这个过程中,我趁机将 draw_view 重新构造为一个更数据驱动的模式,并使场景更有趣。

不幸的是,这些新更改使事情慢了很多。OpenGL 有几个特性可以显著提高性能。下次,我将谈论其中最有力的一个:显示列表。我还会介绍基本的字体处理,并通过在引擎中添加 FPS 显示来继续性能主题。

下次见,祝您玩得开心,继续黑客攻击!

标签

反馈

这篇文章有什么问题吗?请通过在GitHub上创建问题或拉取请求来帮助我们。