编程简介:周末从零开始的简单3D射击游戏,第1部分

本文适用于那些只精通编程的人。 主要思想是逐步展示如何独立制作“ La Wolfenstein 3D ”游戏。 注意,我根本不会与Carmack竞争,他是个天才,他的代码也很漂亮。 我的目标是完全不同的地方:我使用现代计算机的强大计算能力,以便学生可以在几天之内创建有趣的项目,而不会陷入优化的困境。 我专门编写了慢速代码,因为它更短并且更易于理解。 Carmack 写入0x5f3759df ,我写入1 / sqrt(x)。 我们有不同的目标。

我坚信,只有从在家娱乐的人那里获得好的程序员,而不仅仅是在大学里成对坐在一起。 在我们的大学中,为程序员提供了一系列各种各样的图书馆目录和其他无聊内容的培训。 rr 我的目标是展示一些有趣的项目示例。 这是一个恶性循环:如果制作一个项目很有趣,那么一个人会花很多时间在它上面,获得经验,然后看到它周围更多有趣的东西(它变得更容易使用!),然后再次沉浸在一个新项目中。 这被称为项目培训,围绕可观的利润。

纸很长,所以我把文字分成两部分:


我的存储库中的代码执行如下所示:


这不是完成的游戏,对学生来说只是空白。 由两个新生编写的完成游戏的示例,请参阅第二部分

事实证明,我欺骗了您一点,我不会告诉您如何在一个周末内完成完整的游戏。 我只做了一个3D引擎。 怪物不会冲我,主人公也不会射击。 但是至少我在一个星期六写了这个引擎,您可以检查提交的历史记录。 原则上,周日足以使某事变得有趣,也就是说,一个周末可以见面。

在撰写本文时,该存储库包含486行代码:

haqreu@daffodil:~/tinyraycaster$ cat *.cpp *.h | wc -l 486 

该项目依赖于SDL2,但是通常在所有渲染代码都已完成之后,窗口界面和来自键盘的事件处理就显得很晚了, 在星期六的午夜 :)。

因此,我将所有代码分为几步,从裸露的C ++编译器开始。 就像我以前关于时间表的文章( tytstytstyts )一样,我坚持“一步=一个提交”的规则,因为github使得查看代码更改的历史非常方便。

阶段1:将图片保存到磁盘


所以走吧 我们离窗口界面还很远,对于初学者来说,我们只是将图片保存到磁盘上。 总计,我们需要能够将图片存储在计算机的内存中,并以某些第三方程序可以理解的格式将其保存到磁盘。 我想得到这个文件:



这是绘制我们所需内容的完整C ++代码:

 #include <iostream> #include <fstream> #include <vector> #include <cstdint> #include <cassert> uint32_t pack_color(const uint8_t r, const uint8_t g, const uint8_t b, const uint8_t a=255) { return (a<<24) + (b<<16) + (g<<8) + r; } void unpack_color(const uint32_t &color, uint8_t &r, uint8_t &g, uint8_t &b, uint8_t &a) { r = (color >> 0) & 255; g = (color >> 8) & 255; b = (color >> 16) & 255; a = (color >> 24) & 255; } void drop_ppm_image(const std::string filename, const std::vector<uint32_t> &image, const size_t w, const size_t h) { assert(image.size() == w*h); std::ofstream ofs(filename); ofs << "P6\n" << w << " " << h << "\n255\n"; for (size_t i = 0; i < h*w; ++i) { uint8_t r, g, b, a; unpack_color(image[i], r, g, b, a); ofs << static_cast<char>(r) << static_cast<char>(g) << static_cast<char>(b); } ofs.close(); } int main() { const size_t win_w = 512; // image width const size_t win_h = 512; // image height std::vector<uint32_t> framebuffer(win_w*win_h, 255); // the image itself, initialized to red for (size_t j = 0; j<win_h; j++) { // fill the screen with color gradients for (size_t i = 0; i<win_w; i++) { uint8_t r = 255*j/float(win_h); // varies between 0 and 255 as j sweeps the vertical uint8_t g = 255*i/float(win_w); // varies between 0 and 255 as i sweeps the horizontal uint8_t b = 0; framebuffer[i+j*win_w] = pack_color(r, g, b); } } drop_ppm_image("./out.ppm", framebuffer, win_w, win_h); return 0; } 

如果您手边没有编译器,那么这没关系,如果您在github上有一个帐户,则可以直接在浏览器中单击并查看此代码,对其进行编辑并运行(原文如此!)。

在gitpod中:

通过此链接,gitpod将为您创建一个虚拟机,启动VS Code,并在远程计算机上打开一个终端。 在终端命令的历史记录中(单击控制台并按向上箭头),已经有一套完整的命令,可让您编译代码,运行代码并打开生成的图片。

因此,您需要从此代码中了解什么。 首先,我将颜色存储在一个四字节整数类型uint32_t中。 每个字节都是R,G,B或A的组成部分。函数pack_color()和unpack_color()允许您获取每种颜色的单独组成部分。

第二个二维图片,我存储在通常的一维数组中。 为了得到坐标为(x,y)的像素,我不写图像[x] [y],但是我写图像[x + y * width]。 如果您将这种将二维信息打包到一维数组中的方法是新的,那么现在就拿一支笔进行处理。 对我个人而言,这个阶段甚至还没有到达大脑,而是直接在脊髓中处理。 三维数组可以完全相同的方式包装,但我们不会超越这两个组件。

然后,我以一个简单的双循环遍历我的图片,用渐变填充它,然后将其保存为.ppm格式的磁盘。



阶段2:绘制关卡图


我们需要一张我们的世界地图。 此时,我只想确定数据结构并在屏幕上绘制地图。 它看起来应该像这样:



您可以在此处看到更改。 那里的一切都很简单:我将地图硬编码为一维字符数组,定义了绘制矩形的功能,然后在地图上走来走去,绘制了每个单元格。

我提醒您,此按钮将在此阶段启动代码:

在gitpod中:



阶段3:添加播放器


我们需要什么才能在地图上吸引玩家? GPS坐标就足够了:)



添加两个变量x和y,并在适当的位置绘制玩家:



您可以在此处看到更改。 关于gitpod我不会再提醒其他了:)

在gitpod中:



阶段4:又名虚拟测距仪第一射线跟踪


除了玩家的坐标外,很高兴知道他在朝哪个方向看。 因此,我们添加了另一个变量player_a,它给出了玩家的凝视方向(凝视方向与横坐标轴之间的角度):



现在,我希望能够沿着橙色射线滑行。 怎么做? 非常简单。 让我们看一个绿色的直角三角形。 我们知道cos(player_a)= a / c,而sin(player_a)= b / c。



如果我任意取c(正数)并计算x = player_x + c * cos(player_a)和y = player_y + c * sin(player_a),会发生什么? 我们会发现自己处于紫罗兰色的时刻。 通过将参数c从零更改为无穷大,我们可以使此紫色点沿橙色射线滑移,而c是从(x,y)到(player_x,player_y)的距离!

我们的图形引擎的核心是这个周期:

  float c = 0; for (; c<20; c+=.05) { float x = player_x + c*cos(player_a); float y = player_y + c*sin(player_a); if (map[int(x)+int(y)*map_w]!=' ') break; } 

我们沿着射线移动点(x,y),如果它碰到了地图上的障碍物,那么我们结束循环,变量c给出到障碍物的距离! 什么不是激光测距仪?



您可以在此处看到更改。

在gitpod中:



阶段5:行业概述


一束光很好,但我们的眼睛仍然看到整个区域。 我们称视角为fov(视野):



让我们释放512束光线(顺便说一下,为什么要512?),平滑地扫视整个观看区域:


您可以在此处看到更改。

在gitpod中:



阶段6:3D!


现在是重点。 对于512条射线中的每条射线,我们都可以到达最近的障碍物,对吗? 现在,让我们制作另一张512像素宽的图片(扰流器); 其中,对于每条射线,我们将绘制一个垂直段,并且该段的高度与到障碍物的距离成反比:



同样,这是创建3D幻觉的关键,请确保您了解危险所在。 绘制垂直线段,实际上,我们绘制了一个栅栏,每个桩的高度越小,距离我们越远:



您可以在此处看到更改。

在gitpod中:



阶段7:第一个动画


在这个阶段,这是我们第一次绘制动态图像(我只是将360张图片拖放到磁盘上)。 一切都是微不足道的:我更改player_a,绘制图片,保存,更改player_a,绘制,保存。 为了使其更有趣,我为地图中的每种单元格分配了一个随机的颜色值。


您可以在此处看到更改。

在gitpod中:



阶段8:鱼眼矫正


您是否注意到我们在关闭墙壁时会产生什么样的鱼眼效果? 看起来像这样:



怎么了 是的,非常简单。 在这里,我们看墙:



为了绘制墙,我们用紫色射线聚焦我们的蓝色视线。 如下图所示,获取光束方向的特定值。 橙色部分的长度明显小于紫色的长度。 由于要确定我们在屏幕上绘制的每个垂直线段的高度,我们将其除以与障碍物的距离,因此鱼眼非常自然。

要完全纠正这种失真并不困难, 请看一下它是如何完成的 。 请确保您了解余弦来自何处。 在一张纸上画一个图很有帮助。



在gitpod中:



第9步:加载纹理文件


现在该处理纹理了。 我懒得自己写一个图像下载器,所以我选择了出色的stb库 。 我准备了一个带有墙壁纹理的文件,所有纹理均为正方形,并在图像中水平排列:



此时,我只是将纹理加载到内存中。 为了测试编写的代码,我只需在屏幕的左上角绘制索引为5的纹理即可:


您可以在此处看到更改。

在gitpod中:



阶段10:基本使用纹理


现在,我从对应的纹理中获取左上像素,以排除随机生成的颜色并为墙壁着色:


您可以在此处看到更改。

在gitpod中:



阶段11:将墙壁纹理真实


现在,当我们终于看到砖墙时,等待已久的时刻到了:



基本思想非常简单:在这里,我们沿着当前射线滑动并停在x,y点。 假设我们定居在“水平”墙上,则y几乎是整数(不是真的,因为我们沿射线移动的方式引入了一个小的误差)。 让我们将x的小数部分称为hitx。 小数部分小于1,因此,如果将hitx乘以纹理的大小(我有64),那么这将为我们提供需要在此位置绘制的纹理列。 它仍然可以将其拉伸到合适的大小,并且它在帽子里:



通常,这个想法非常原始,但是需要仔细执行,因为我们也有“垂直”墙(hitx接近于零[x整数]的墙)。 对于它们而言,纹理列由y的hity决定。 您可以在此处看到更改。

在gitpod中:



第十二阶段:重构时间!


在这个阶段,我没有做任何新的事情,我只是开始常规清洁。 到现在为止,我只有一个巨大的文件(185行!),但是在其中工作变得很困难。 因此,不幸的是,我将它分成了很多小块,几乎不加任何功能地将代码大小增加了一倍(319行)。 但是随后,使用它变得更加方便,例如,生成动画,足以进行这样的循环:

  for (size_t frame=0; frame<360; frame++) { std::stringstream ss; ss << std::setfill('0') << std::setw(5) << frame << ".ppm"; player.a += 2*M_PI/360; render(fb, map, player, tex_walls); drop_ppm_image(ss.str(), fb.img, fb.w, fb.h); } 

好吧,这是结果:


您可以在此处看到更改。

在gitpod中:

待续...立即


基于这种乐观的看法,我完成了表格的当前一半,后半部分可以在此处获得 。 在其中,我们将添加怪物并链接到SDL2,以便您可以在虚拟世界中散步。

Source: https://habr.com/ru/post/zh-CN439698/


All Articles