使用Perl构建3D引擎
本文是关于构建完整3D引擎系列文章的第一篇。它可能是视频游戏的基础技术,科学应用的可视化系统,建筑设计套件的浏览程序,或者任何其他用途。
编辑注:还可以查看该系列的其余部分,例如事件和键盘处理,光照和运动,以及应用程序性能分析。
首先,我会设定一些目标和基本规则,以帮助指导设计。我非常支持敏捷编程,但即使是敏捷的开发过程也需要一些基本目标
- 我不会制作小型演示。早期,引擎功能不会很多,但应该始终是未来增长的坚实基础。
- 引擎必须在各种架构和操作系统之间可移植。我将使用OpenGL进行3D渲染,并使用SDL进行一般操作系统交互,例如输入处理和窗口创建。引擎本身应包含尽可能少的操作系统特定代码。
- 引擎应在每一步都可用,从开始就可用。我将在一段时间内逐步完善它,可能有些复杂的概念需要一些时间来解决,但至少每篇文章都应该以整个引擎再次工作结束。
- 我将省略大部分错误检查以节省空间并使核心概念更加清晰。出于同样的原因,没有包含测试库。在你自己的引擎中,你将想要两者都有!
- 不要害怕实验。学习这些东西的最好方法是与之互动。从文章中的内容开始,并添加更多。现在花的时间将会在未来回报很多倍,因为当你对早期主题有扎实理解时,理解高级主题会更容易。
在我们开始之前,最后一点是,一些SDL_Perl版本存在会影响引擎的bug。如果我知道有哪些问题需要注意,我会告诉你;相反,如果你发现任何bug,请告诉我,我将在下一篇文章中包含一个注释。
入门
第一步是草拟一个简单的结构,并立即创建一个可运行的程序。请耐心等待;这里有一段代码,但对于它所做的事情来说,这将会在以后简化事情。以下是我的起点
#!/usr/bin/perl
use strict;
use warnings;
my ($done, $frame);
START: main();
sub main
{
init();
main_loop();
cleanup();
}
sub init
{
$| = 1;
}
sub main_loop
{
while (not $done) {
$frame++;
do_frame();
}
}
sub do_frame
{
print '.';
sleep 1;
$done = 1 if $frame == 5;
}
sub cleanup
{
print "\nDone.\n";
}
前几行是常见的严格模板,尤其是在我无网(测试库)的情况下工作尤为重要。然后我声明了几个状态变量(一个“完成”标志和帧计数器),然后跳到主程序。
主程序相当简单——初始化,运行一段时间的主循环,然后清理。这典型地反映了如何构建一个可能复杂的程序。顶层例程应该非常简单、清晰且自文档化。每个概念性部分是一个独立的例程,其中包含所有实际工作的粗糙部分。我见过巨大的程序(数十万行),其中主程序从数百行初始化开始,最终才到达“真正”的主体。那种风格难以调试、难以分析性能,而且难以理解。我绝对避免它。
回到当前程序。init
在STDOUT
上设置自动刷新,以便部分行立即打印,这在以后的do_frame
中会用到。
主循环简单地循环直到$done
为真,每个循环产生一个完成的动画帧。每个循环增加帧计数器并调用实际的工作例程do_frame
。
do_frame
打印一个点来表示帧的开始,并暂停一秒钟。当它醒来时,它会检查是否完成了五个帧,如果是,则设置$done
。
设置$done
后,main_loop
结束,控制返回到main
,然后调用最终的cleanup
。 cleanup
只是通知用户干净退出并结束。
要打印两行文本(在五秒内)和退出,这段代码已经足够多了;它甚至没有打开渲染窗口!我会在下一步做这个。
创建窗口
首先,我需要引入SDL和OpenGL库
use SDL::App;
use SDL::OpenGL;
并添加几个额外的状态变量(一个配置散列和一个SDL::App
对象)
my ($conf, $sdl_app);
初始化
我将进行两种新的初始化类型,因此我创建了相应的例程并在init
中调用它们
sub init
{
$| = 1;
init_conf();
init_window();
}
sub init_conf
{
$conf = {
title => 'Camel 3D',
width => 400,
height => 400,
};
}
sub init_window
{
my ($title, $w, $h) = @$conf{qw( title width height )};
$sdl_app = SDL::App->new(-title => $title,
-width => $w,
-height => $h,
-gl => 1,
);
SDL::ShowCursor(0);
}
在这个阶段,init_conf
只定义了一些在init_window
中立即使用的配置属性,其中包含了一些真正的SDL内容。
init_window
执行两个重要的操作。首先,它要求SDL::App
创建一个新窗口,并带有适当的标题、宽度和高度。《em>-gl选项告诉SDL::App
将OpenGL 3D渲染上下文附加到该窗口而不是默认的2D渲染上下文。其次,它使用SDL::ShowCursor(0)
隐藏鼠标光标(当它在新窗口的边框内时)。
绘图的三阶段
现在我有一个不错的新窗口,我想让do_frame
用它做些事情。我将开始将渲染分成三个阶段:准备、绘制和完成。
sub do_frame
{
prep_frame();
draw_frame();
end_frame();
}
目前,draw_frame
包含的正是do_frame
之前所包含的内容
sub draw_frame
{
print '.';
sleep 1;
$done = 1 if $frame == 5;
}
新代码在prep_frame
和end_frame
中;让我们首先看看prep_frame
sub prep_frame
{
glClear(GL_COLOR_BUFFER_BIT);
}
这是第一个实际的OpenGL调用。在解释细节之前,值得指出OpenGL的命名约定。OpenGL的设计允许它与没有任何命名空间或包概念的语言一起工作。为了解决这个问题,所有OpenGL例程名称看起来像glFooBar
(驼峰式,没有下划线,以gl
开头),所有OpenGL常量名称看起来像GL_FOO_BAR
(大写,单词之间有下划线,以GL_
开头)。在较老的语言中,这可以防止OpenGL名称与在其他库中使用的名称冲突。在Perl世界中,由于面向对象的模块不会出现这种情况,因此SDL_Perl利用这个约定,在您编写use SDL::OpenGL
时将所有名称导入到当前包中。
注意:如果您阅读了用C编写的OpenGL代码,您可能会注意到例程名称后附加了一个简短的字符串,如3fv
。这个约定区分了具有不同参数数量或参数类型不同的变体。在Perl中,值知道自己的类型,函数的参数数量可以变化,因此这是不必要的。Perl绑定简单地删除这些额外的字符,而SDL::OpenGL
会为您做正确的事情。
在prep_frame
中的OpenGL调用通过调用glClear
将渲染区域清空为黑色——这是一个通用的OpenGL“清空缓冲区”例程——使用一个表示应该清除颜色缓冲区的常量。正如其名所示,颜色缓冲区存储每个像素的颜色,这就是用户看到的内容。还存在其他几个OpenGL缓冲区;我将在稍后描述它们。
细心的读者可能会想知道,为什么代码要将颜色缓冲区清空为黑色,而不是白色或其他颜色。OpenGL在很大程度上依赖于当前状态的概念。许多OpenGL例程实际上并不请求任何渲染,而是改变当前状态中的一个或多个变量,以便下一个渲染命令执行不同的操作。当程序准备使用OpenGL时,比如SDL::App::new
为我们做的那样,当前状态被设置为(大部分)合理的默认值。其中之一是清除颜色缓冲区时使用的颜色。它的默认值是黑色,我没有麻烦去覆盖它。
剩下的例程是end_frame
sub end_frame
{
$sdl_app->sync;
}
这会要求SDL::App对象将窗口内容与OpenGL颜色缓冲区中持有的内容同步,以便用户可以看到渲染的图像。在这种情况下,它是一个持续五秒的黑色窗口。
有所见之处
是时候在窗口中绘制一些东西了。为此,我需要做三件事
- 选择一个投影,这样OpenGL就知道我希望如何查看场景。
- 设置视图,这样OpenGL就知道从哪个方向查看场景(视点)以及我希望朝哪个方向看。
- 在场景中定义一个对象,放置在视点可以看到的位置。
首先,我需要另一个配置设置,因此我将在init_conf
中的$conf
散列中添加另一行。
fovy => 90,
接下来,对于我的三个新函数,我在draw_frame
的顶部添加了三个新调用。
sub draw_frame
{
set_projection_3d();
set_view_3d();
draw_view();
选择一个投影
set_projection_3d
如下所示
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;
}
这是你第一次看到3D图形艰难的数学部分——数学,而且很多。3D渲染代码通常包括大量的线性代数(对于那些阻断了高中和大学岁月的人来说是矩阵数学)和三角学。幸运的是,OpenGL在幕后做了很多数学运算。我定义了一个相当简单的投影和视图,所以这隐藏了现在的很多复杂性(除了OpenGL函数名之外)。
例程的第一部分定义了视图投影。在最简单的情况下,这意味着选择是否使用正交投影或透视投影。正交投影没有透视。它们通常出现在建筑和工程图纸中,因为相同大小的部分无论在场景中的位置如何,都看起来相同。
透视投影是我们用我们的眼睛或相机在现实世界中看到的东西;远处的物体看起来比近处的物体小。这也是你在透视绘画艺术课上学到的,在透视绘画艺术课中,最常见的作业是延伸到地平线的火车轨道。离观察者更远的轨道看起来更靠近,连接之间的间隔也是如此。为了复制现实世界,我选择了一个透视投影。
在OpenGL中,你不仅要在正交投影或透视投影之间做出选择,还要定义其基本尺寸。换句话说,你能看到多少?对于透视投影,你定义垂直视野(FOV),视图的纵横比以及最近和最远可见事物的距离。
垂直FOV(代码中的$fovy
)定义了从视点到场景中最低和最高可见部分的夹角。如果你想象一个人站在视点处,用眼睛看到的东西,这代表她的垂直视野。如果你想象一个相机,这取决于镜头的焦距。长焦镜头的FOV非常小,因为从相机到顶部和底部可见物体的角度非常小。相反,广角镜头的FOV很大,鱼眼镜头的FOV更大,接近180度。
画布的宽高比直接来自画布尺寸(宽度/高度)。这允许OpenGL补偿非正方形窗口的拉伸效果。在这种情况下,画布是正方形的,因此宽高比为1。
计算完窗口的宽高比后,我告诉OpenGL我想修改投影并从空白状态开始,使用glMatrixMode(GL_PROJECTION)
和glLoadIdentity
。然后我调用gluPerspective
来定义所需的透视。你可能注意到gluPerspective
以glu
开头,而不是像我们见过的其他调用那样以gl
开头。这是因为我在使用GLU(OpenGL实用程序)例程来掩盖等效原始OpenGL序列中的某些复杂性。
最后,我切换回模型/视图模式,并再次从空白状态开始,使用glMatrixMode(GL_MODELVIEW)
和glLoadIdentity
。你可能想知道为什么我不在下一个例程中包含这个,而在这里做。我喜欢确保更改常用OpenGL状态的例程,简单地作为其主要目的的副作用,将其恢复到原始状态,尤其是如果没有净性能影响的话。在这种情况下,我临时切换到投影模式,然后切换回默认的模型/视图模式。
设置视图
下一步是将视点移动到我们可以看到场景的位置
sub set_view_3d
{
# Move the viewpoint so we can see the origin
glTranslate(0, -2, -10);
}
现在我将跳过详细说明,但简而言之,glTranslate
调用将视点移动到场景原点附近(并在上方),我在那里放置我的对象。我保持默认的观看方向,因为它恰好指向我想要的方向。
定义一个对象
我将从一个相当简单的场景开始——只有一个对象
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;
}
这个单独的对象本身非常简单,只是从原点沿着X、Y和Z轴延伸的三个短线。(我在OpenGL的意义上使用“线”,指的是线段,而不是严格的数学中的无限线。)
在OpenGL中,当你想要定义渲染的内容时,你必须通知OpenGL你开始和结束定义的时间;这些是glBegin
和glEnd
调用。此外,你必须告诉OpenGL你将使用什么类型的原语来创建你的对象。有几种原语类型,包括点、线和三角形。此外,每种原语类型都有基于多个原语在序列中如何连接的变体(独立地、条带状连接等)。在这种情况下,我使用GL_LINES
,表示独立放置的线段。
我想让每条线都有不同的颜色,以便更容易区分它们。为了设置当前的绘制颜色,我使用RGB(红、绿、蓝)三元组调用glColor
。在OpenGL中,每个颜色成分的范围是从0(无)到1(全)。因此,(1,0,0)表示纯红色,(0,1,0)是纯绿色,以此类推。中等灰色是(.5,.5,.5)。为了更好的记忆价值,我分配颜色,使得RGB三元组与线的端点坐标相匹配——红色对应X轴,绿色对应Y,蓝色对应Z。
对于每条线,在定义颜色后,我使用glVertex
定义线的端点。每条线从原点开始,沿着适当的轴延伸一个单位。换句话说,这个序列定义了一条从(0,0,0)到(1,0,0)的红色线。
glColor(1, 0, 0);
glVertex(0, 0, 0);
glVertex(1, 0, 0);
有了这些例程,我们终于可以看到一些东西了!正如你所看到的,X轴指向右边,Y轴指向上方,Z轴指向屏幕外的观察者(OpenGL将其缩短)。注意对象首次出现前的延迟;这是因为draw_frame
末尾的休眠在end_frame
将屏幕与绘制区域同步之前创建了一个暂停。
移动盒子
接下来,让我们尝试一个盒子。任何玩过第一人称射击游戏的人都知道,他们的世界中有很多盒子(也就是“板条箱”、“储物容器”等等 - 奇怪的是,对于储物容器,它们越大,似乎包含的东西越少)。我会从一个简单的立方体开始,并在draw_view
的末尾添加另一个对该立方体的调用。
sub draw_view
{
draw_axes();
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]);
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;
}
看起来很复杂,但实际上并不难。@vertices
数组包含了边长为两单位的立方体的坐标,以原点为中心,其边与X、Y和Z轴对齐。@indices
数组定义了立方体六个面的四个顶点,以及将它们发送到OpenGL的顺序。顺序非常重要;我安排得使得从外部看,每个面的顶点按照逆时针顺序绘制。使用一致的顺序有助于OpenGL确定每个多边形的正面和背面;我选择使用默认的逆时针顺序。
定义了这些数组后,我使用glBegin(GL_QUADS)
标记一系列独立四边形原语的开始。然后,我遍历每个面的每个顶点,找到正确的坐标集合,并使用glVertex
将它们发送到OpenGL。最后,我使用glEnd
标记这个原语序列的结束。
当然,纯朴的Perl程序员可能会想知道为什么我选择了C风格的循环(附带索引计算,真恶心),而不是让@indices
成为一个数组的数组。大多数情况下,我只是想表明处理这类输入数据并不太难。当引擎从文件中读取对象描述,而不是手动编写的程序时,文件解析器的自然输出可能是扁平化的。通常,进行一点索引计算比强迫解析器输出更多结构化数据(也许更高效,但那显然需要基准测试)要容易。
结果是有一个蓝色的立方体。为什么是蓝色?因为我没有指定使用的新颜色,OpenGL就回到了当前状态,并查找了当前绘图颜色。轴的最后一行是蓝色的,这仍然是当前颜色。因此,就有一个蓝色的立方体。
两个有颜色的盒子
让我们来修复这个问题。同时,我们可以将新的立方体移出轴线的位置,以便我们再次可以看到它们。见鬼,我会来个彻底的,有两个立方体 - 一个在轴线左侧,一个在轴线右侧。好的地方在于,因为我只是绘制了我已经描述的东西的更多内容,所以我只需要更改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();
}
现在,我在绘制第一个立方体之前使用glColor(1, 1, 1)
将当前颜色设置为白色,在绘制第二个立方体之前使用glColor(1, 1, 0)
将其设置为黄色。glTranslate
调用应该将第一个立方体放置在左侧两单位(沿负X轴),第二个立方体放置在右侧两单位(沿正X轴)。
累积变换
不幸的是,这并没有奏效。白色立方体在左侧两单位处,但黄色立方体又回到了轴线线上,不是预期的右侧两单位。这是因为glTranslate
调用(以及我稍后会展示的其他变换调用)是累积的。与glColor
之类的简单设置当前状态的例程不同,大多数变换调用实际上是以某种方式修改当前状态。因此,第一个立方体从(-2, 0, 0)开始,第二个立方体从(-2, 0, 0) + (2, 0, 0) = (0, 0, 0)开始——又回到了原点。
解决这个问题需要稍微看看内部结构。OpenGL 变换调用实际上只是设置一个特殊的矩阵,表示请求的变换对坐标的影响。OpenGL 然后将当前矩阵乘以这个新的变换矩阵,并用乘积的结果替换当前矩阵。
为了解决这个问题,我需要一种方法在执行变换之前保存当前矩阵,并在完成后恢复它。幸运的是,OpenGL 实际上维护了一个每种类型的矩阵的栈。我只需要在绘制白色立方体之前将当前矩阵的副本推入栈中,然后再将其弹出以返回到变换之前的那个状态。我将为两个立方体都这样做
sub draw_view
{
draw_axes();
glColor(1, 1, 1);
glPushMatrix;
glTranslate(-2, 0, 0);
draw_cube();
glPopMatrix;
glColor(1, 1, 0);
glPushMatrix;
glTranslate( 2, 0, 0);
draw_cube();
glPopMatrix;
}
这好多了。黄色立方体的原点现在位于 (2, 0, 0),正如预期的那样。
其他变换
之前我提到了其他的变换调用;让我们看看其中的一些。首先,我会缩放盒子(改变它们的大小)。我将统一缩放左边的(白色)盒子——换句话说,以相同的量缩放其每个维度。为了显示差异,我将非统一地缩放右边的(黄色)盒子,每个维度缩放不同。以下是新的 draw_view
sub draw_view
{
draw_axes();
glColor(1, 1, 1);
glPushMatrix;
glTranslate(-4, 0, 0);
glScale( 2, 2, 2);
draw_cube();
glPopMatrix;
glColor(1, 1, 0);
glPushMatrix;
glTranslate( 4, 0, 0);
glScale(.2, 1, 2);
draw_cube();
glPopMatrix;
}
对于白色盒子,我只是将每个维度加倍;glScale
的参数是X、Y和Z的乘数。对于黄色盒子,我将X维度缩小了5倍(乘以.2),Y维度保持不变,并将Z维度加倍。盒子现在足够大,所以我还将它们推得更远,因此glTranslate
的更新值将它们放置在场景原点两侧各四单位的位置。
注意旋转
我已经完成了平移和缩放;接下来是旋转。为了节省空间,我将只演示黄色立方体。以下是新的代码片段
glColor(1, 1, 0);
glPushMatrix;
glRotate( 40, 0, 0, 1);
glTranslate( 4, 0, 0);
glScale(.2, 1, 2);
draw_cube();
glPopMatrix;
glRotate
的参数是要旋转的度数和旋转的轴。在这种情况下,我选择围绕Z轴(0, 0, 1)旋转40度。旋转的方向遵循OpenGL中的通用模式——正值意味着当沿着旋转轴朝向原点看时,逆时针旋转。
变换顺序
这产生了右上象限中的飞行黄色盒子。记得我之前说过每个新的变换都是累积的吗?顺序很重要。为了理解原因,我倾向于想象每个变换都是移动、旋转或缩放我在其中绘制对象的坐标系。在这种情况下,通过先旋转,我确实旋转了盒子,但我实际上旋转了我定义盒子的整个坐标系。这意味着紧随其后的glTranslate
调用沿着旋转的X轴向外移动,精确地说是在场景X轴上方40度。
我将旋转移动到其他两个变换之后来修复这个问题
glTranslate( 4, 0, 0);
glScale(.2, 1, 2);
glRotate( 40, 0, 0, 1);
现在盒子没有飞行,但它以奇特的方式看起来像是压扁了。这里的问题是,因为非均匀缩放发生在旋转之前,我现在正在尝试在一个维度不同大小的空间中旋转。将旋转放在中间可以解决这个问题
glTranslate( 4, 0, 0);
glRotate( 40, 0, 0, 1);
glScale(.2, 1, 2);
如果您将此版本的渲染与没有glRotate
调用的程序进行比较,您应该会看到它现在做得正确。
哇,太深入了!
我最后要提到的是,当某个位于后面的东西在某个位于前面的东西之后绘制时应该怎么做。为了说明我的意思,我将白色盒子移动到场景原点左侧四单位,而不是四单位之外(沿着负Z轴)。这仅仅涉及改变白色盒子的glTranslate
调用,从这样
glTranslate(-4, 0, 0);
到这样
glTranslate( 0, 0, -4);
如您所见,即使白色框应该出现在坐标轴线后面,但它却出现在了前面,因为OpenGL在坐标轴线之后绘制了它。默认情况下,OpenGL假定你是故意这样做的(这样做更高效),但我并不是。为了解决这个问题,我需要告诉OpenGL注意场景中各种对象的深度,不要将远处的对象覆盖到近处的对象上。
为此,我需要启用OpenGL的深度缓冲区。这类似于颜色缓冲区,它存储每个像素的绘制颜色。然而,它存储的是每个像素的深度(从视点到观察方向的距离)。就像颜色缓冲区一样,我需要每帧清除深度缓冲区。OpenGL不是将其清除为黑色,而是将其清除为最大深度值,这样在可见场景中任何后续的渲染都会更近。
我还需要告诉OpenGL,每次它想要绘制一个像素时,都应该进行测试,比较新像素的深度与深度缓冲区中已有的值。如果新像素比它将要替换的像素更远,那么可以忽略新像素并保留旧的颜色。以下是更新的prep_frame
代码。
sub prep_frame
{
glClear(GL_COLOR_BUFFER_BIT |
GL_DEPTH_BUFFER_BIT );
glEnable(GL_DEPTH_TEST);
}
在这个版本中,我告诉glClear
清除颜色缓冲区和深度缓冲区。你现在可以理解为什么常量名以_BIT
结尾;实际上,它们是位掩码。这种奇特的接口纯粹是为了效率 - 一些OpenGL实现可以非常快速地同时清除所有请求的缓冲区,并且通过在一个调用中请求所有需要的缓冲区来实现这种优化。至于选择位掩码而不是常量列表,SDL_Perl反映了底层的C接口,这样熟悉C接口的人可以更容易地过渡到使用Perl的OpenGL。
我调用的第二个例程glEnable
实际上是最常用的OpenGL例程之一,尽管我们第一次看到它。OpenGL当前状态的大部分是一组标志,这些标志告诉OpenGL何时(或不)执行某些操作。glEnable
和相应的glDisable
根据需要设置这些标志。在这种情况下,我打开了一个标志,告诉OpenGL执行深度测试,丢弃绘制顺序错误的像素。
经过这些更改,我们现在可以再次看到坐标轴线,这次它们位于白色框前面,这正是它们应该出现的位置。
结论
最终结果可能看起来很简单,但我们已经走了很长的路。我从一个基本的模板和一个简单的主循环开始。我甚至没有加载SDL或OpenGL,也没有打开窗口。到最后一刻,我添加了一个绘图窗口;设置投影和视图;使用不同的OpenGL原语构建的不同类型的多个对象,以不同的颜色绘制,并以几种不同的方式变换;以及正确处理顺序错误的绘制。
这已经很多了,但我们才刚刚开始。下次我会介绍移动视点、SDL键盘处理和补偿帧率变化。我将在本文中构建的示例源代码的基础上进行,所以请随意下载并用于您的应用程序。
标签
反馈
这篇文章有什么问题吗?请通过在GitHub上打开问题或拉取请求来帮助我们。