视频游戏设计/编程/框架/2D vs 3D/3D 引擎
如果游戏需要 3D 环境,则表示它将使用 3D 视图,其特点是使用基于多边形的图形。多边形是扁平的形状,在低数量(低分辨率多边形场景)中,图形通常是角形的。
一个好的 3D 引擎应该以相当的速度运行,无论整个世界的规模如何;速度应该与实际可见的细节数量成正比。当然,如果速度只取决于你想要绘制的像素数量,那会更好,但由于显然还没有人找到一个能做到这一点的算法,我们只能尝试改进过去的工作。
本节将尝试描述 3D 引擎架构中的组件。3D 引擎包含许多概念。我们将尽力涵盖使用 3D 引擎的其他系统(例如 AutoCAD、Blender、医疗程序等),但我们将重点关注游戏中的 3D 引擎。
游戏引擎是一个非常广泛复杂的主题。它们有自己的架构和方法,针对特定游戏或游戏类型中必须具备的功能。一个引擎的工作方式不一定就是所有引擎的工作方式,甚至大多数引擎都不是,也就是说每个引擎都是独特的创作。
世界的 3D 性质将要求大多数概念艺术在获得批准后被扫描并建模到 3D 建模应用程序(如 Maya 或 3DsMax)中的平面中。
一些流行的 3D 图形引擎:水晶空间、Irrlicht、Ogre3D
3D 建模和动画示例:AutoDesk(以前称为 Alias)Maya、3dbuzz、AutoDesk 3dsMax、Blender、TrueSpace(现在免费)
基本上,基于传送门的引擎允许对通常构成虚拟世界的庞大数据集进行“空间索引”:换句话说,它可以是一种简单的技术,可以避免在每帧都显示所有这些数据,而是只绘制相关的数据,甚至可以节省快速内存需求。
一个基本的传送门引擎依赖于表示世界的 dataset。世界被细分为区域,我称之为“扇区”。扇区通过“传送门”连接,因此得名“传送门引擎”。基于传送门的引擎世界可以被认为是 边界表示(或 B-rep)数据结构的一种变体,它对游戏的世界的属性进行了专门化。渲染过程从摄像机所在的扇区开始。它绘制当前扇区中的多边形,当遇到传送门时,就会进入相邻的扇区,处理该扇区中的多边形。当然,这仍然会绘制世界上所有的多边形,假设所有扇区都以某种方式连接。但是,并非所有传送门都是可见的。如果传送门不可见,则它链接到的扇区不必绘制。这很合乎逻辑:一个房间只有当摄像机到该房间有一条视线且该视线没有被墙壁遮挡时才可见。
所以现在我们有了我们想要的东西:如果一个传送门不可见,跟踪就会在那裡停止。如果那个传送门後面有一个很大的世界,那个部分永远不会被处理。因此,实际处理的多边形数量几乎完全等于可见的多边形数量加上插入的传送门多边形。
现在应该也清楚了在世界中应该在哪些地方插入传送门:传送门的理想位置是门、走廊、窗户等等。这也清楚地说明了为什么传送门引擎不适合户外场景:在户外场景中几乎不可能选择好的传送门位置,每个扇区几乎可以“看到”世界上其他所有扇区。但是,传送门渲染可以与户外引擎完美结合:如果用其他类型的引擎渲染你的景观,你可以在洞穴、建筑物等的入口处放置传送门。当“普通”渲染器遇到一个传送门时,你可以简单地切换到传送门渲染,以处理该传送门後面的所有内容。这样,传送门引擎甚至可以很适合“太空模拟”……
有时现有的 3D 引擎无法满足游戏的要求、功能或许可。 3D 游戏引擎是庞大的软件项目。一个人确实可以编写一个,但这不是一个一夜之间就能完成的过程。它可能会导致有超过几兆字节的源代码。如果没有足够的决心完成它,尝试就会失败,甚至会危及整个游戏项目。
在开始编写引擎之前,需要进行一些计划工作,就像游戏本身一样,以便确定将如何使用引擎或可以实现什么。通常,创建 3D 引擎源于特定游戏的需求,但它们本身也可以被视为产品。不要指望第一次就能编写一个完整的引擎,如果必须构建引擎并且时间或知识不足,请让合适的人来做,或者将工作重点放在一个更小的游戏项目上,该项目对引擎的要求列表同样很小。
不要直接开始编写引擎,因为会出错,并且需要多次重写引擎的大部分内容才能添加效果和控制。对引擎设计进行一些预先思考可以节省时间和精力。
3D 引擎中的功能列表可以包括曲面、动态照明、体积雾、镜子和门户、天空盒、顶点着色器、粒子系统、静态网格模型和动画网格模型。如果一个人对所有这些东西的工作原理有很好的了解,并且具有实现它们的技术能力,他可能会将它们组合成自己的引擎。
在本节中,我们将讨论 3D 引擎中通常存在的元素。这些本质上包括工具、控制台、系统、渲染器、引擎核心、游戏界面和游戏本身。
让我们看一下基本组件,以一个功能齐全的 3D 游戏引擎可能的结构划分为例。这尤其针对那些对 3D 比较了解的人,但需要了解游戏引擎需要多少工作量和多少部分。
在开发过程中,将需要处理特定数据,不幸的是,这不像编写一些定义立方体的文本文件那么简单。至少需要处理由 3d 模型编辑器、关卡编辑器和图形程序生成的數據。
创建原始数据的工具可以购买,也可以免费获得。不幸的是,将需要进行特定于域的编辑和数据处理,这些编辑和数据处理特定于您的游戏,这些工具可能还不存在,因此需要创建它们。
这可能是一个关卡编辑器,如果找不到满足所需功能的编辑器。可能还需要编写一些代码来将文件打包到一个档案中,因为处理和分发数百或数千个文件可能会很麻烦。还需要编写各种 3d 模型编辑器格式到您自己格式的转换器或插件,以及处理游戏数据所需的所有工具,例如可见性计算或光照贴图。
基本原则就是,您在工具方面的代码可能与实际游戏代码一样多,甚至更多。您可以找到可使用的格式和工具,但迟早您会意识到需要一些非常适合您的引擎的东西,最终您还是会自己编写。
虽然可能急于完成工具以便能够使用,但在编码时也要注意。将来可能有人会尝试使用、扩展或修改您的工具,尤其是如果您将引擎设为开源或可修改的。
您可能应该做好准备,在制作图形、关卡、声音、音乐和模型上花费与编写游戏、工具和引擎一样多的时间。
系统是引擎与机器本身通信的部分。如果引擎编写得很好,系统是唯一在移植到不同平台时需要进行重大修改的部分。系统内包含几个子系统。图形、输入、声音、计时器、配置。系统负责初始化、更新和关闭所有这些子系统。
图形子系统非常简单。如果它处理将东西放在屏幕上,它就归这里管。您最有可能使用 OpenGL、Direct3D、Glide 或软件渲染编写此组件。为了更花哨,您可以实现多个 API 接口,并在它们之上放置一个图形层,为用户提供更多选择以及更多兼容性和性能机会。但这有点难,尤其是因为并非所有 API 都有相同的特征集。
输入子系统应该接收所有输入(键盘、鼠标、游戏手柄和操纵杆),并对其进行统一,并允许对控制进行抽象。例如,在游戏逻辑中的某个地方,将需要查看用户是否要向前移动其位置。与其对每种类型的输入进行多次检查,不如对输入子系统进行一次调用,它将透明地检查所有设备。这还允许用户轻松配置,并轻松地让多个输入执行相同的操作。
声音系统负责加载和播放声音。非常简单,但大多数当前游戏都支持 3D 声音,这会让事情变得更复杂。
3d 游戏引擎中的几乎所有东西(我在这里假设的是实时游戏……)都基于时间。因此,您需要在计时器子系统中使用一些时间管理例程。这实际上很简单,但由于任何移动的东西都将随时间移动,因此一组不错的例程可以防止您一遍又一遍地编写相同的代码。
配置单元实际上位于所有其他子系统之上。它负责读取配置文件、命令行参数或使用的任何设置方法。其他子系统在初始化和运行时查询此系统。这使得更改分辨率、颜色深度、按键绑定、声音支持选项甚至加载的游戏变得很容易。使您的引擎可配置性很高,这使得测试变得更容易,并且允许用户按照自己的喜好设置内容。
在这里我们将讨论诸如 Windows 下的屏幕访问之类的事情。除此之外,还将提供一些关于双缓冲、像素绘制等方面的基本信息。
在过去,有 DOS。在 DOS 中,有模式 13。一旦初始化,不知情的编码人员就可以写入视频内存,从一个固定地址开始,如果我没记错的话,是 A0000。所有这些都没有阻塞计算机,并且在世界上几乎所有 VGA 卡上都能正常工作。该模式允许输出 256 色图形,如果你做得好,这就可以了。
但后来发生了一件非常糟糕的事情。SVGA 卡开始出现在各个地方,更糟糕的是,计算机变得足够快以至于可以进行真彩色图形。从那一刻起,一切都不再确定。您有 15 位图形(出于某种非常奇怪的原因,有人决定忽略一个完整的位),16 位图形、24 位、32 位,以及这些位数组中颜色组件的随机排序。例如,您可以遇到一张做 RGB 的卡,但也可以做 BGR 或 RGBA……因此,VESA 标准被发明出来。这使事情再次变得可以忍受。
如您所知,一旦出现标准,就会有其他人(微软)认为可以做得更好。因此,Windows 被“展示”给世界(或者被强塞到我们喉咙里,您喜欢哪个)。从那一刻起,您永远无法确定您的处理器在特定时刻在做什么。它可能正在运行您的应用程序,但也可能在您演示的某个关键时刻切换到另一个“重要”应用程序……当您屈服于微软并开始编写“本机”应用程序时,情况会变得更糟:绘制到窗口是一场灾难,需要“锁定”,而且速度更慢。
好了,抱怨够了。让我们尝试着接受现状。我总是使用一个缓冲区,因为它看起来很像旧的模式 13。当我有这样的缓冲区时,我会尝试尽可能高效地将其传送到一个窗口。这是一个绝对必要的但毫无趣味的步骤。我最喜欢的“工具包”是 MGL:一个来自 SciTech 的相当庞大的库,SciTech 是 UNIVBE 的制作方,也就是“显示医生”。这个库允许你相对容易地创建一个窗口,并获取指向它的指针。但是,它仍然需要“锁定”,这意味着你无法很好地调试:在“锁定”期间,没有屏幕更新。这可以通过使用你自己的缓冲区并在完成操作后将其复制到窗口来部分解决。在这种情况下,锁定仅在复制过程中需要,这通常不是什么大问题。MGL 库有一个小问题:它做的不仅仅是显示窗口。例如,它还可以绘制线条和多边形,并且包含 OpenGL 内容。由于这种不必要的负担,你必须将一个 1.5 兆字节的库链接到你的程序,才能打开一个窗口...... 所以,还有更简单的方法,我想介绍给你。它被称为“PTC”,是“Prometheus True Color”的缩写,或者类似的东西。它是一个非常简单的 Direct X 包装器,允许你在各种位深度和分辨率下进行全屏图形输出,仅此而已。因此,这个库非常小。打开显示只需要几个(易于理解和记忆的)命令。之后,你就可以完全访问显示器了。还有一个问题:可怕的锁定。这也同样可以通过缓冲区复制来解决。
我必须提到的 PTC 的一大优势是它与平台无关:PTC 也适用于 Linux、BeOS 和其他一些操作系统。因此,如果你的程序使用 PTC 进行图形输出,你只是在“使用”Windows,而不是被迫朝着它思考。这也让将来迁移到其他操作系统变得更加容易,因为你的代码只需稍作修改即可再次运行。
所以,给你留一点家庭作业:访问 http://www.gaffer.org/,并获取 PTC 库。看看一些示例程序,你会发现它们非常简单。完成之后,我们就可以继续了。
这是一个可以玩的小 PTC 应用程序
#include "ptc.h"
#include <stdlib.h>
int APIENTRY WinMain (HINSTANCE hInst, HINSTANCE hPrevInst, LPSTR lpCmdLine, int nCmdShow){
try{
Format format(32,0x00FF0000,0x0000ff00,0x000000ff);
Console console;
console.open("demo",320,200,format);
Surface surface(320,200,format);
int32* pixels=(int32*)new int32[320*200];
while (!console.key()){
int32* surf=(int32*)surface.lock();
for (int i=0; i<(320*200); i++){
*(surf+i)=*(pixels+i);
}
surface.unlock();
surface.copy(console);
console.update();
}
return 0;
}
catch (Error &error){
error.report();
}
}
解释:这里的大部分内容可能都比较清楚。我在缓冲区部分添加了几行:首先,我声明了一个与 PTC 显示器大小相同的缓冲区,该缓冲区在每次“锁定”期间都会被复制到 PTC 表面。这使表面实际锁定的时间尽可能短,防止了错误代码导致的挂起。
注意:在本系列的剩余部分,我们将完全忽略 Windows 部分。假设你已经设法获得了用于绘制的缓冲区,以便我们可以专注于有趣的东西:硬核 3D。
现在我们已经完成了恼人的缓冲区和窗口部分,就到了真正的主题:向量数学。矩阵很酷。它们也相当难以完全理解,所以如果你想了解有关它们的一切,现在就是寻找一个关于它们的优秀网站或书籍的时候了(最好是一个网站,别忘了将链接提交到这个网站!)。
首先我想谈论另一件事:收集知识。我经常收到人们的电子邮件,询问我如何学习我的所有知识。“你能推荐什么书?”“你从哪里开始?”,类似这样的问题。我对此的通常反应是和我的妻子聊聊天,谈论“自学成才”:你必须自己学习东西。不要依赖他人的知识,自己收集信息。更重要的是:尝试。你不会通过读书变得优秀,你会通过犯错变得优秀。通过浏览网络收集信息,这是世界上最大的信息来源。而且它通常比书店更及时。使用这种方法,可以培养出其他人没有的知识:你以独特的方式将收集到的知识组合在一起,使你能够做其他人没有做过的事情,尝试别人没有想到的事情。这就是我获得(可疑的)知识的方式。这有点难以解释,但我希望你明白了我的意思。:)
好的,回到矩阵。矩阵可以用来定义 3D 空间中的旋转和平移。因此,我通常使用 4x4 矩阵:3x3 定义任意旋转,最后一列定义移动,也就是平移。然后可以通过将坐标(向量)放入矩阵中来计算旋转后的坐标。
假设你将矩阵定义为一个浮点数数组:四行四列,例如名为 cell[4][4]。你可以使用以下公式“放入”一个向量
x_rotated = cell[0][0] * x + cell[1][0] * y + cell[2][0] * z + cell[3][0] y_rotated = cell[0][1] * x + cell[1][1] * y + cell[2][1] * z + cell[3][1] z_rotated = cell[0][2] * x + cell[1][2] * y + cell[2][2] * z + cell[3][2]
注意最后一排没有使用,所以理论上你甚至不必存储它。
那么我们如何创建矩阵本身呢?我们从单位矩阵开始
1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
这个矩阵具有独一无二的特性,它对 3D 坐标没有任何影响:它完美地保留了它们。然后我们获取围绕 x 轴的所需旋转,这里我将其称为“rx”。此旋转的矩阵如下所示
1 0 0 0 0 cos(rx) sin(rx) 0 0 -sin(rx) cos(rx) 0 0 0 0 1
所以,如果你只想围绕 x 轴旋转你的 3D 坐标,你可以使用这个矩阵。但是,如果你还想围绕 y 和 z 轴旋转,你必须将 x 旋转矩阵与 y 和 z 旋转矩阵“连接”。它们如下所示
y
cos(ry) 0 -sin(ry) 0 0 1 0 0 sin(ry) 0 cos(ry) 0 0 0 0 1
z
cos(rz) sin(rz) 0 0 -sin(rz) cos(rz) 0 0 0 0 1 0 0 0 0 1
好的,那么我们如何连接矩阵呢?方法如下:连接后的矩阵的 Cell [x][y] 等于行乘以列的总和。一些 C 代码解释了我的意思
for (c=0; c<4; c++) for (r=0; r<4; r++) conc[c][r] = m1[0][r] * m2[c][0] + m1[1][r] * m2[c][1] + m1[2][r] * m2[c][2] + m1[3][r] * m2[c][3];
注意连接矩阵的顺序很重要!这是合乎逻辑的:如果你先围绕 x 轴旋转 90 度,然后围绕 z 轴旋转 45 度,你会得到与你以相反顺序执行这些旋转不同的旋转。
这些是基本知识。以下是如何在实践中应用它们。想象你有一个立方体。它位于 3D 空间中的 (100,100,100) 处,应该围绕该坐标旋转。它的旋转是 (rx, ry, rz),当然我们会在一个漂亮的循环中改变 rx、ry 和 rz,以便有一个旋转的立方体。立方体的顶点可以定义如下
1: -50, -50, -50 2: 50, -50, -50 3: 50, 50, -50 4: -50, 50, -50 5: -50, -50, 50 6: 50, -50, 50 7: 50, 50, 50 8: -50, 50, 50
注意立方体最初是在 3D 原点定义的。这是因为我们希望旋转它:如果你旋转一个以 (100,100,100) 为中心的立方体,它也会围绕 3D 原点旋转,导致在 3D 空间中进行大范围的扫描。立方体的中心,也就是它的平移,应该是 (100,100,100)。因此,我们首先使用 rx 创建一个矩阵。我们为 ry 创建另一个矩阵,并将这两个矩阵连接起来。然后我们为 rz 创建另一个矩阵,并将它连接起来。最后添加平移,这可以通过直接更改相关的矩阵元素 cell[3][0]、cell[3][1] 和 cell[3][2] 来完成(分别对应 x、y 和 z)。然后我们将顶点放入最终矩阵中,瞧!当然需要处理旋转后的坐标,才能在屏幕上显示它们。你可以使用以下公式来实现
screen_x = rot_x * 500 / rot_z + 160; screen_y = rot_y * 500 / rot_z + 100;
注意我在这里使用 160 和 100 作为 320x200 缓冲区的中心。只要生成的 2D 坐标不在屏幕外,就可以直接在显示器上绘制它们。
好的,这些是基本知识。这里还有一些内容来激发你的胃口:你不需要在连接 rx、ry 和 rz 后就停止。例如,如果你有两个立方体,其中一个围绕另一个立方体旋转,你可以为第二个立方体定义一个只有围绕 x 轴旋转的旋转矩阵。如果你将该矩阵连接到“父”对象的矩阵,那么旋转的立方体将被第一个立方体的矩阵加上它自己的矩阵旋转。这使得构建一个“层次结构”的对象系统成为可能:例如一个机器人,它的手臂相对于机器人的身体旋转(将手臂矩阵连接到机器人矩阵),手相对于手臂旋转(再次连接)。
你还可以使用矩阵做一些其他很酷的事情,比如反转它们。这可以用在快速表面剔除中,我以后会详细介绍,这篇文章已经有点长了。
这是一种几乎被引擎中所有其他系统使用的系统。这包括你所有的数学例程(向量、平面、矩阵等)、内存管理器、文件加载器、容器(如果你不自己编写,可以使用 STL)。这些东西非常基础,你参与的几乎所有项目都可能用到。
啊,是的,每个人都喜欢渲染 3D 图形。由于渲染 3D 世界的方法有很多,几乎不可能给出适用于所有情况的图形管道的描述。
无论你如何实现渲染器,最重要的是使你的渲染器成为基于组件的,并且干净。确保你为不同的事情创建了不同的模块。我将渲染器分成以下子部分:可见性、碰撞检测和响应、相机、静态几何体、动态几何体、粒子系统、贴图、网格、天空盒、灯光、雾化、顶点着色和输出。
这些部分中的每一个都需要有一个接口,以便轻松地更改设置、位置、方向或与系统相关的任何其他设置。
要注意的一个主要陷阱是功能膨胀。在设计阶段决定你将要实现什么功能。如果你没有添加新功能,那么开始变得难以做到,并且解决方案会变得笨拙。
一个好且方便的方法是让所有三角形(或面)最终通过渲染管道的同一个点。(不是一次一个三角形,我指的是三角形列表、扇形、条带等)。将所有内容转换成可以通过相同的灯光、雾化和着色代码处理的格式需要更多工作,但最终你会很高兴,因为只要更改其材质/纹理 ID,你就可以对游戏中任何多边形执行的任何效果都可以在任何多边形上执行。
从多个点绘制东西并没有什么坏处,但如果你不小心,会导致冗余代码。
你最终会发现,你想要实现的所有那些酷炫的效果只占你最终编写的代码的 15% 或更少。没错,游戏引擎的大部分内容都不是图形。
我正在开发一个名为 **Focus** 的轻量级 3D 引擎实验,我只是用它来尝试一些小东西,比如双线性插值、阴影和一般的快速软件渲染。当然,这个小引擎不是我的第一个引擎,因此我想谈谈 3D 数据结构。我发现放弃一个引擎并从头开始的主要原因是“糟糕的设计”。当然,重写不是问题:从头开始通常可以改进代码,因为你可以快速地重写以前引擎中痛苦地编写的代码,而且通常代码更简洁、更高效。早期的引擎经常有一些限制,我只能通过进行重大修改或重写才能摆脱这些限制。这些限制中的大多数仅仅存在于数据结构的限制。以下是一些例子。
我的第一个 3D 引擎之一(E3Dengine)完全用 Pascal 编写。多边形在内部存储为四边形凸多边形,因为我的世界只包含正方形,所以一开始这似乎还不错。经过几个月的编码,我开发了一个室内渲染引擎,具有 6DOF(顺便说一下,这是在 Quake 发布之前),我遇到了第一个重大问题:与 Z=0 平面进行裁剪。对于室内引擎来说,这是必要的,因为多边形通常部分位于你的身后(与对象渲染引擎不同,对象渲染引擎通常整个对象都在你的面前)。当将透视应用于相机后面的顶点时,这些顶点会发生奇怪的事情:请记住上一篇文章中提到的透视公式,其中旋转后的 x 和 y 坐标除以它们的深度。一旦 x 和 y 坐标超过 z=0,它们就会被此公式取反,而在 z=0 上,该公式会导致“除以零”。从多边形中裁剪某些东西的问题在于,多边形会获得多余的或更少的顶点。我的结构可以处理 3 个顶点,但不能处理 5 个顶点…… 在这种情况下,我可以将所有数组中的空间增加到每个多边形 5 个顶点,但这会弄乱我的汇编例程,因为它们期望某些数据位于内存中的固定位置。
经验教训
- 在引擎开发的早期阶段,不要使用汇编程序。在 Pascal 中,有时仍然需要使用它,但最新的 C++ 编译器真正将汇编程序变成了只在最后优化步骤中使用的工具。
- 不要使用固定值来表示诸如多边形中顶点的数量之类的东西。更一般地说,使数据结构可扩展。
最近的引擎有其他限制:对象记录无法处理分层对象系统,多边形记录无法轻松扩展以包含凹凸数据,等等。所以我想你可能从我的伤疤中学到了一些东西……
一个现代的 3D 引擎需要相当多的数据,而且这些数据存在于不同的层次。以下是我认为你需要的内容(自上而下)
1. 世界结构 2. 扇区(局部)结构 3. 对象结构 4. 多边形结构
在理想情况下,所有这些都应该尽可能通用,以避免过度重写,从而导致潜在的士气低落,并让有才华的新程序员离开 3D 领域。:) 这是一个不好的事情。
好的,让我们继续讨论细节。在顶层(世界结构)上,你应该考虑如何组织一个世界,如果你要构建一个需要类似东西的引擎。我的意思是,如果你是一个初学者,你可能会像我一样从一个对象渲染器开始。所以,将这个对象视为一个“世界”可能很诱人,因为无论如何没有其他数据要处理。你可能只是声明一个顶点数组,旋转它们,并将它们转储到屏幕上。很好。但想想这个:如果你的顶点数组存储在一个更大的结构中,称为“对象”,你可能会(很可能)尝试让你的引擎使用两个或更多个对象。以下是一些你在确定适合自己的世界结构时需要考虑的因素。
一个世界可能太大了,无法在每一帧中显示或甚至处理。因此,你需要一些结构来允许部分处理。最好不要考虑看不见的世界部分和对象。
这意味着世界和其中的对象之间必须存在某种联系。否则,你将渲染所有对象,加上世界可见的部分,这仍然可能太繁重了。
你可能还想有一个或多或少的动态世界。这再次需要不同的数据结构。以下是我如何实现顶层。
class World { SectorList* sectors; };
以下是扇区的样子。
class Sector { PolygonList* polygons; ObjectList* objects; };
大多数引擎的工作原理都是如此:即使是 BSP 引擎也是如此。这样,只要对象每次移动时都存储在正确的扇区中,对象就会直接链接到世界。因此,对象可见性问题现在直接与世界可见性问题相关联。
你应该考虑的下一个层次(或者如果你只做对象引擎,那么是第一个层次)是对象数据结构。以下是一个示例结构。
class Object { PolygonList* polies; ObjectList* childs; Matrix matrix; };
这个结构中有一些有趣的地方。首先,我总是将指向此结构中对象列表的指针放在这个结构中。这样做是有充分理由的:许多对象以某种方式链接在一起。如果它们是链接在一起的,它们的矩阵通常以增量的方式确定:例如,手臂相对于躯干旋转,因此它需要自己的矩阵和其父节点(躯干)的矩阵。躯干本身可能相对于世界旋转:因此,我也在 Object 类中放置了一个矩阵。
好的,再往下一个层次,多边形层次。以下是一个基本的多边形类。
class Polygon { VertexList* vertices; Texture* texture; Sector* sector; Plane plane; };
以及顶点类。
class Vertex { Coordinate* position; float U, V; };
最后是坐标类。
class Coordinate { Vertex original; Vertex rotated; boolean processed; };
这是有趣的东西,因为即使是初学者也会遇到这种情况。让我们从多边形类开始:在本例中,我使用了 VertexList 类来保存我的顶点。这使得在多边形中拥有任意数量的顶点成为可能,但也有一个缺点:每个列表条目也具有一个“next”指针(假设它是一个链表)。因此,每个顶点比你预期的多占用 4 个字节。如果这不是一个大问题,请考虑以下情况:另一种选择是数组。数组是按顺序存储的,通常对缓存更好。但数组的尺寸是固定的…… 在 Focus 中,我最终选择了数组方法。因此,你必须在实例化多边形时指定顶点的数量。我的多边形类如下所示。
class Polygon { Vertex** vertices; int vertices; Texture* texture; Sector* sector; Plane plane; };
因此,有一个指向内存块的单个指针,该内存块保存指向顶点的指针。请注意,我仍然使用额外的内存:一个用于顶点数量的整数(因为列表末尾不再有空指针),以及指向指针块的指针。
在多边形类中要注意的另一件事是扇区指针。它用于门户多边形:由于门户链接了两个扇区,因此这是你应该存储此信息的地方。
最后,还有一个平面结构。如果你想重复使用平面(例如,如果你有很多位于同一平面的多边形),你也可以将它设置为平面指针。如果你不知道如何处理平面:我会在以后的文章中详细介绍。
在 Vertex 类中存储坐标指针也是循环利用的原因。立方体有八个顶点,但六个面都有 4 个顶点,总共 24 个顶点。在立方体的情况下,很明显,只有八个顶点的 3D 位置是唯一的。然而,纹理坐标不需要由同一位置的两个多边形顶点共享。因此,顶点类应该包含 U/V 信息(纹理坐标),以及指向 3D 位置的指针。这节省了空间和处理时间,因为旋转后的顶点不必再次旋转。这让我想到坐标类。
在坐标类中,可能比你预期的要多一些东西。首先,它既包含一个原始向量,也包含一个旋转后的向量。你需要保留原始向量以确保最大精度:即使是高精度的浮点算术也会在反复旋转坐标时快速引入数值不稳定性。另一个有趣的是“processed”字段:它指示顶点是否已针对当前帧旋转。这消除了重复旋转。
以下是针对更高级程序员的一条小建议:可以使用整数而不是布尔值。如果你将当前“帧 ID”存储在这个整数中(只需在每一帧中增加它),你就不用在每一帧中重置所有旋转顶点的“processed”标志。当很难在绘制过程结束时确定巨大的顶点集中哪些顶点已旋转时,这特别有用。
好的,这就是我想说关于 3D 数据结构的内容。我的目的是让你在早期就开始考虑这个问题:你设置数据的方式可能是在设计引擎时做出的最重要的决定。简而言之,实际的建议是:使用指针和列表,尽可能少使用数组。数组最初看起来很简单,但它们很快就会让你的引擎变得更复杂,迫使你做一些 C++ 本身不支持的丑陋的事情。
编码线框立方体的示例
[edit | edit source]我可以就 PTC 和 Windows 编程、数据结构等等喋喋不休,但最终没有什么比一个好的例子更好了。嗯,我刚刚编写了一个非常小的程序(实际上只有 54 行代码),它只显示了一个线框立方体,这可能正是你需要的。以下是代码。
#include "ptc.h" #include "math.h" float angle, x[8], y[8], z[8], rx[8], ry[8], rz[8], scrx[8], scry[8];
void line (unsigned short* buf, float x1, float y1, float x2, float y2) { double hl=fabs(x2-x1), vl=fabs(y2-y1), length=(hl>vl)?hl:vl; float deltax=(x2-x1)/(float)length, deltay=(y2-y1)/(float)length; for (int i=0; i<(int) length; i++) { unsigned long x=(int)(x1+=deltax), y=(int)(y1+=deltay); if ((x<640)&&(y<480)) *(buf+x+y*640)=65535; } }
void render (unsigned short* buf, float xa, float ya, float za) { float mat[4][4]; // Determine rotation matrix float xdeg=xa*3.1416f/180, ydeg=ya*3.1416f/180, zdeg=za*3.1416f/180; float sx=(float)sin(xdeg), sy=(float)sin(ydeg), sz=(float)sin(zdeg); float cx=(float)cos(xdeg), cy=(float)cos(ydeg), cz=(float)cos(zdeg); mat[0][0]=cx*cz+sx*sy*sz, mat[1][0]=-cx*sz+cz*sx*sy, mat[2][0]=cy*sx; mat[0][1]=cy*sz, mat[1][1]=cy*cz, mat[2][1]=-sy; mat[0][2]=-cz*sx+cx*sy*sz, mat[1][2]=sx*sz+cx*cz*sy, mat[2][2]=cx*cy; for (int i=0; i<8; i++) // Rotate and apply perspective { rx[i]=x[i]*mat[0][0]+y[i]*mat[1][0]+z[i]*mat[2][0]; ry[i]=x[i]*mat[0][1]+y[i]*mat[1][1]+z[i]*mat[2][1]; rz[i]=x[i]*mat[0][2]+y[i]*mat[1][2]+z[i]*mat[2][2]+300; scrx[i]=(rx[i]*500)/rz[i]+320, scry[i]=(ry[i]*500)/rz[i]+240; } for (i=0; i<4; i++) // Actual drawing { line (buf, scrx[i], scry[i], scrx[i+4], scry[i+4]); line (buf, scrx[i], scry[i], scrx[(i+1)%4], scry[(i+1)%4]); line (buf, scrx[i+4], scry[i+4], scrx[((i+1)%4)+4], scry[((i+1)%4)+4]); } }
int APIENTRY WinMain (HINSTANCE hInst, HINSTANCE hPrevInst, LPSTR lpCmdLine, int nCmdShow) { for (int i=0; i<8; i++) // Define the cube { x[i]=(float)(50-100*(((i+1)/2)%2)); y[i]=(float)(50-100*((i/2)%2)), z[i]=(float)(50-100*((i/4)%2)); } Console console; // Initialize PTC and start rendering Format format (16, 31<<11, 63<<5, 31); console.open ("3D", 640, 480, format); Surface surface (640, 480, format); while (!console.key ()) { unsigned short* buf=(unsigned short*) surface.lock (); memset (buf, 0, 640*480*2); render (buf, angle, 360-angle, 0); angle+=0.2f; if (angle==360) angle=0; surface.unlock(); surface.copy (console); console.update(); } return 0; }
请花点时间让这些东西运行起来。使用 VC5 或 VC6,创建一个没有文件的新的 Win32 应用程序。将 PTC 库文件添加到项目中(使用“添加文件”),以及 ptc.h 头文件,当然还有我刚刚展示的源代码。接下来,你必须禁用两个默认库的包含(在“链接器输入设置”下):LIBC 和 LIBCD。现在程序应该可以正常编译。你可以尝试通过将在一行中执行多个变量初始化的代码行拆分来使其更具可读性。:)
那么这个程序做了什么?让我们从程序入口开始,在本例中是 WinMain 函数。这里发生的第一件事是立方体的定义。立方体有八个顶点,可以按如下方式初始化。
1. x: -50 y: -50 z: -50 2. x: 50 y: -50 z: -50 3. x: 50 y: 50 z: -50 4. x: -50 y: 50 z: -50 5. x: -50 y: -50 z: 50 6. x: 50 y: -50 z: 50 7. x: 50 y: 50 z: 50 8. x: -50 y: 50 z: 50
使用模运算符的奇怪结构正是这样做的(只是更短:)。如果你花点时间可视化这些数据,你会发现这些是围绕 (0, 0, 0) 的 8 个顶点,这很方便,因为围绕 3D 空间的原点进行旋转很容易。
之后,我们需要设置 PTC。在本例中,我使用了 16 位显示器,分辨率为 640x480 像素。这种视频模式应该适用于大多数计算机。
在主循环中,调用函数 'render',并使用指向 PTC 缓冲区的指针和围绕三个轴的旋转作为输入。请注意,旋转以度为单位传递。
函数 'render' 稍微有趣一些。让我们看看它需要做什么:最终它应该在旋转的顶点之间绘制线条,并且这些线条应该位于屏幕中心附近。旋转是使用矩阵完成的。如果你忘记了它是如何工作的,请回到讨论它们的这篇文章。如你所知,可以通过计算围绕每个轴的旋转矩阵,然后将它们连接起来,来执行围绕三个轴的旋转。在本例中,我已经为你完成了连接:矩阵 'mat' 同时填充了正弦和余弦值。我建议你修改代码,以便通过连接各个矩阵来计算最终矩阵,这样你也可以以不同的顺序围绕轴旋转。
旋转后的顶点仍然以原点为中心。由于透视计算会对 z 坐标进行除法,因此我们需要将物体移离相机。这是通过将 300 添加到旋转后的 z 来完成的。请注意,你也可以向 x 和 y 添加一些内容:这就是你在除了原点之外的其他地方旋转物体的方式。在本例中,物体实际上是围绕 (0, 0, 300) 旋转的。
最后,计算透视。请注意,通过将 320 添加到屏幕 x 坐标,将 240 添加到屏幕 y 坐标,物体也在屏幕上居中。
现在可以将线条绘制到屏幕上。我包含的线条函数非常短,这是它的唯一优点。如果你需要快速代码,请放弃此函数,并包含你自己的汇编语言 bresenham 代码。关于此代码的一些评论
它首先确定需要绘制多少像素。如果线条的垂直范围大于水平范围,它会绘制 abs(y2-y1) 个像素,否则绘制 abs(x2-x1) 个像素。这可以防止出现间隙。
绘制像素时,可以通过将某些内容添加到第一个 x 坐标 (x1) 和第一个 y 坐标 (y2) 来计算每个后续屏幕位置。这个 '某些内容' 实际上是 x 或 y 范围除以要绘制的像素总数。当你仔细想想,在将 'n' 次添加一个位到 x 和 y 之后,逻辑上就会到达 (x2,y2),其中 'n' 是计算的像素数量。还要注意,'delta-x' 或 'delta-y' 恰好为 1。
如果你想用这段代码玩一玩,以下是一些建议
- 编者注:请随时提交你所做的任何修改后的程序版本,我可能会在这里发布它,并附上你的姓名。
修改线条绘制代码,使其接受其他颜色。目前颜色始终为 65535,在 16 位颜色模式下为纯白色。这种颜色由红色、绿色和蓝色组成:红色为 5 位,绿色为 6 位,蓝色为 5 位。最终颜色使用以下公式计算:red*2048+green*32+blue。请注意,红色应该在 0 到 31 之间的整数,蓝色也是如此。绿色是在 0 到 63 之间的整数。
稍微调整一下物体的位置。它也可以部分超出屏幕,线条绘制代码不会崩溃。
尝试创建除立方体以外的其他物体。使用此代码,你可以在壮丽的 3D 中设计你自己的姓名。
扩展数据结构,以便不再硬编码顶点之间的连接。例如,你可以定义边,它应该包含一个起始顶点和一个结束顶点。然后渲染代码应该绘制所有边,如果你有很多边,这将使代码变得更加美观。你也可以引入 '多边形',它包含两个以上的顶点。
添加一个立方体,并使用矩阵文档中的内容使第二个立方体围绕第一个立方体旋转。为了使操作正确,在 (100,0,0) 处构建第二个立方体,以便旋转使它围绕第一个立方体 '摆动'。
光线
[edit | edit source]雾
[edit | edit source]液体
[edit | edit source]如果要追求真实感,模拟水或其他液体可能会非常困难。水的细节会随着观察者的距离而改变,它可能会有泡沫、透明度、扭曲和镜面反射。水面的反射性、随机性和复杂性极高,难以模仿,尤其是当玩家可以与水进行交互或物理属性可变时,例如模拟碎片飞溅或包含液体的环境的变化。
控制台
[edit | edit source]我知道每个人都喜欢随大流,拥有像 Quake 这样的控制台。但这确实是一个好主意。使用控制台变量和函数,你可以更改游戏和引擎中的设置,而无需重新启动它。在开发过程中,这对于输出调试信息非常有用。通常情况下,你需要检查一些变量,将它们输出到控制台比运行调试器更快,有时也更好。一旦你的引擎运行起来,如果发生错误,你就不必退出应用程序;你可以优雅地处理它,只需打印一条错误消息。如果你不希望你的最终用户看到或使用控制台,可以很容易地禁用它,没有人会知道它仍然存在。
游戏界面
[edit | edit source]3D 游戏引擎最重要的部分是它是一个游戏引擎。它不是游戏。实际的游戏永远不应该包含放入游戏引擎中的组件。在引擎和游戏之间设置一层薄薄的层可以使代码更干净,更易于使用。它只是一些额外的代码,但也使游戏引擎非常可重用,并且使用脚本语言进行游戏逻辑或将游戏代码放在库中变得更容易。如果你将任何游戏逻辑嵌入引擎本身,不要打算在不遇到大量问题和修改的情况下重新使用它。
所以你可能想知道引擎和游戏之间这层提供了什么。答案是控制。对于引擎中具有任何动态属性的每个部分,引擎/游戏层都提供了一个修改它的界面。此类别中的一些内容包括相机、模型属性、灯光、粒子系统物理、播放声音、播放音乐、处理输入、更改关卡、碰撞检测和响应以及用于抬头显示、标题屏幕或任何其他内容的 2D 图形的放置。基本上,如果你想让你的游戏能够做到这一点,那么必须有一个界面进入引擎才能做到这一点。