FlapPGA Mario – 用 FPGA 编写游戏

完整代码参考:https://github.com/howardlau1999/flapga-mario

构想蓝图

在学习数字电路课程的时候,教授向我们提出了自愿完成一个 FPGA 项目的要求,还展示了往年学生的成果。可是大部分人选择完成的是多周期 MIPS CPU 的设计,有点无趣,其中有一个让我印象深刻,那就是有人用 FPGA 实现了一个类似打砖块的游戏,当时我就觉得这太酷了,所以我也选择了用 FPGA 做一个游戏。但是当时的我对 FPGA 一无所知,连 Verilog 的语法都不清楚,原本的构想是在 FPGA 上实现一台 FC,但是很快我就发现这不现实,所以我给自己定的目标是,不用 CPU,单纯用 FPGA 的硬件语言来实现一个游戏,而这个游戏最好达到下面的目标:

  • 由于没有 CPU,游戏逻辑不能太复杂
  • 但是要够好玩
  • 要有彩色画面,最好有动画
  • 最好还没有人实现过

经过一番搜索,我在网上看到了 Yoshi’s Nightmare 这篇博客,给了我很大的启发。一开始我想设计一个类似于“是男人就下一百层”的游戏,只不过主角换成了马里奥,后来发现这个也不简单,所以最终我确定了做 FlapPGA Mario,一个类似于 Flappy Bird 的游戏。下面先上视频~

架构概览

参考 FC 等游戏机的硬件设计,经过设计中的不断迭代,最终形成了下图所示的系统架构:

VGA 模块

要想进行视频输出,最底层的模块就是 VGA 同步单元,负责向显示器输出同步信号和像素信息,虽然这部分比较复杂,但由于是底层基础模块,所以有现成的代码可以直接使用,基于 Don’t reinvent the wheels 原则,我就直接拿来主义了,嘿嘿。当然,VGA 输出的原理还是要了解一下的。

VGA 同步单元

VGA 的扫描方式和 CRT 显示器很类似,有一条扫描线从左到右,从上到下地扫描每一个像素点,显示器则在收到同步信号之后点亮对应的像素点到指定的颜色。VGA 控制器需要发出 HS 信号和 VS 信号对显示器进行同步,然而,并不是每一个扫描点都可以显示像素,下面这幅图展现了扫描的过程:

可以看到,在水平方向的左右,各有一段 “Porch” 时间,这个时间在一行扫描完成之后 CRT 显示器稳定电压的时间,因为 CRT 是通过控制电压来控制电子偏转的,电压的下降(也就是回到最左边)和稳定需要一点时间,在电压不稳定的情况下,不能输出像素信息;类似的,最上面和最下面也各有一段 “Porch” 时间,具体的时间和时钟周期由时钟频率和帧率还有分辨率共同决定,下面给出 640 x 480 @ 60Hz 的时钟信息:

到这里,只需要按照表格信息编写 Verilog 程序就好了。同时,这个模块需要输出当前扫描的像素点的 x 坐标和 y 坐标。

图形引擎

Basys 3 的 VGA 接口对 RGB 每个分量分别提供 4 位输出,也就是 12 比特颜色位深,可以显示 4096 色。经过简单的计算,Basys 3 提供的 BRAM 有 1800000 位比特,但是如果要将像素信息放到显存中则需要 640 x 480 x 12 = 36864000 位比特,很显然这是远远不够的。所以我们需要根据当前 VGA 的像素坐标实时计算出像素点的颜色信息,事实上,在内存相当吃紧的年代,FC 也是这么做的,而 FC 有专门的 PPU (Picture Processing Unit) 进行相当复杂的像素操作,虽然不能直接实现一个 PPU,但是其中不少设计思想还是可以借鉴的。

ROM 和 RAM

确定了存储方式之后,就可以编写 ROM 和 RAM 代码,RAM 的代码用的是 Vivado 自带的模板代码,ROM 的代码就更简单了,直接声明寄存器数组就可以了,这里我偷了个懒,存储图像的 ROM 我选择直接将坐标信息的 x, y 拼接成地址信息。要将数据在综合时读取到寄存器中的话,要用到 $readmemh 这个系统指令,网上有很多说明指令的使用方法,这里不再赘述。

背景图层

一般而言,游戏机会有两个图层,一个是背景图层,用来显示大块的图片,但不能自由移动,在内存中只存储下面的信息:

第 8 位 第 7 位 第 6 位 第 5:3 位 第 2:0 位
启用 上下翻转 左右翻转 Tile 行 Tile 列

这里 Tile 指的就是在背景 ROM 中存储的图像块,长宽固定。那么我们怎么知道当前的块要显示在什么地方呢?这由内存信息的地址决定,按行存储,直到铺满画面为止。这样就可以节省大量的空间,因为不用存储坐标信息了。

活动图层

另一个图层就是活动图层,一般用来显示需要灵活移动的图像块,比如游戏的主角等等,这时候我们就要存储具体的坐标信息了,占用的位数会更多:

第 31 位 第 27 位 第 26 位 第 25:16 位 第 15:6 位 第 5:3 位 第 2:0 位
启用 上下翻转 左右翻转 Y 坐标 X 坐标 Tile 行 Tile 列

同样地,Tile 的长宽是固定的。

读取 ROM

读取 ROM 的方式其实就是通过 Tile 行列信息和坐标信息,计算出在 ROM 中的地址,实现图像数据的存取。计算的方式很简单:如果当前坐标还没到达图像块的显示范围,就不读取;到达范围之后,读取的地址就是当前的 x, y 坐标减去图像块的 x, y 坐标,再加上 Tile 行列对应的偏移即可。

图层融合

两个图层需要在渲染的时候融合起来,每个图层的引擎需要负责输出当前的像素是否为透明的信息,然后由上层模块通过这个信息和遮挡关系计算出最终显示的像素信息,这样需要我们确定一个透明色,当引擎遇到这个颜色的时候就认为当前像素是透明的(注意和黑色不同),我选择的是纯蓝,下面展示游戏用到的背景图像块和活动图像块:

音频引擎

由于 Basys 3 没有 DAC 模块,我们需要用 PWM 来模拟正弦波。尽管方波更方便,正弦波听上去好一点。实现音频输出的办法是先对正弦波进行离散采样成 64 个点:

Basys 3 的系统时钟为 100 MHz,那么我们的计数器应该设置成 100MHz / freq,这样就是指定频率的方波,由于一个周期内有 64 个点,这个计数器值还需要除以 64,得到 100MHz / freq / 64,然后我们设置一个计数器,每当计数器的值达到这个值的时候,就取下一个采样点,然后在计数周期内一直输出这个幅度值。

而 PWM 模块就根据幅度值进行 PWM 脉冲调整,达到输出正弦波的目的。需要注意的是,我们需要将幅度值变成正数。

而存储在游戏里的音乐不是波形,而是一个个音符:

第 31:16 位 第 15:0 位
频率计数器(计算后的) 持续时间(ms)

游戏逻辑

马里奥

这个游戏其实只用到一个上键,每当我们按键的时候,需要将马里奥的状态设置成“上升”,并切换人物动画,然后给定一个初始计数器值,每当计数器到达零,就让马里奥上升一个像素,然后给一个大一点的计数器值,这样就实现了有重力的上升,直到计数器值达到最大设定值为止,此时把状态设置成“下降”,马里奥开始掉落,同样给一个初始计数器值,不过这个计数器值是慢慢变小的,就是实现了加速下落的效果。马里奥模块需要告诉上层模块当前的位置,方便碰撞检测。

水管生成

首先确定水管信息如何记录,需要存储的信息有:水管左端的 X 坐标,水管上端的结束 Y 位置,下端的开始 Y 位置,全部是以背景图像块作为索引。游戏中水管的数量其实是恒定为 3 个的,并且设置好了初始值,按照一定周期向左移动,当检测到一个水管到达了画面最左端,就重置信息到最右边,并随机给两端开始结束赋值。游戏引擎根据水管信息向背景内存写入图像信息。

金币生成

如果仅仅是躲水管,有点无聊,所以还加入了金币这个设定,生成的方式和存储信息和水管大同小异,不同的是金币有动画效果,同样以一定周期更新动画帧数信息,然后游戏引擎根据信息写入背景内存。

碰撞检测

由于游戏中各个图像都是方形,碰撞检测其实就是检测两个矩形有没有交集,为了简单起见,这里不考虑像素透明的问题。如果马里奥碰撞到了水管,就设置一个 game_over 标志,游戏不再进行;碰到金币则设置一个 coin_eaten 标志,并重新生成金币。

分数统计

当一个水管的 X 坐标小于马里奥并且此时游戏没有结束,就可以认为马里奥成功通过了水管,给分数加上一分,如果马里奥吃到了金币,那么就加十分。分数显示在屏幕的最上方。

滚动逻辑

前面提到,背景图层是整整齐齐按照图像块的方式排列的,那么要怎么滚动呢?方法是设置一个 scroll_x_offset 变量来对整个画面进行向左偏移,只需要将其加在传给背景引擎的 x 值上就能达到目的。这个变量的最大值应该是 Tile 的宽度减 1,当这个值达到最大的时候,我们需要更新所有水管的 X 坐标,使其减 1,这样就可以造成水管连续向左滚动的效果了!

滚动分裂

前面提到,画面的最上方显示的是分数,分数也是通过背景来显示的,如果分数跟着水管滚动,那看上去就太蠢了!所以我们需要用到“滚动分裂”的技术,就像下图所示:


横线表示当前的扫描行,竖线表示滚动偏移量,可以看到在显示分数的时候,偏移量为 0,等到显示游戏内容,才开始设置偏移量。因此我们也可以借鉴一下,当 y 扫描分数的时候,我们不设置偏移量,等到 y 进入游戏内容范围,才设置应有的偏移量。这样就可以分开滚动的内容。其实利用这个技巧,可以达到画面扭曲,伪 3D 等奇妙的效果,虽然简单,但是强大!

视差滚动

视频里可以看到背景图滚动速度比水管要慢,制造出了视差滚动效果,其实这个效果实现很简单,就是给最底层的滚动逻辑传一个分频过的(也就是频率变慢了的)时钟信号即可~

数据读写控制

由于没有 CPU,加上当时也没有数据总线这些概念……所以我就粗暴用时钟来控制不同游戏数据的写入,首先将水管数据写入背景内存,然后将金币写入,然后是分数显示,而对象内存独立于背景内存,也就不用轮流写入了。

总结

完整代码参考:https://github.com/howardlau1999/flapga-mario

前前后后,这个项目花了我三周的课余时间,回头看其实个人觉得完成度还是不错的 XD。从零开始写一个游戏,还是在 FPGA 上,而且我还没有基础,非常具有挑战性,但也非常有趣。在这个过程中,其实曾推倒过一次项目重来,因为当时设计的显示方法有缺陷 🙁

完成这个项目需要极大的恒心和细心,由于是输出到 VGA,没有办法通过仿真波形调试,而且没有调试工具可以用,也没有太多的参考资料,需要大量的思考,甚至需要借鉴一些老式游戏机的设计思路,比如 FC 就给了我很多很多的启发(感谢任天堂!)。而每次修改之后都要烧板验证,通过观察程序表现推测可能出错的代码,并且需要仔细阅读才能找出一些细微的错误。而且在项目初期,遇到了很多失败,使得我多次产生放弃的念头,但是我还是想坚持把这个项目真真切切地做出来。

完成项目还需要多方面的知识,例如图像、音频的存储和输出等,还需要协调大量的模块,对于设计能力也是一个极大的考验。总之,做这个项目,我个人感觉收获良多!

展望

当然,由于只花了三周时间(还是课余)来完成,这个项目改进空间还很大,比如:

  • 由于没有 CPU,游戏的复杂度受限,而且远没有充分发挥 Basys 3 的潜力,未来可以考虑加入 CPU
  • 音频引擎只能反复播放同一段 BGM,也没有音效
  • 活动块引擎最多只能同时处理 8 个活动块

不过考虑到时间,我个人还是很满意这个成果的~

附录

图像和音频的转换

图像处理比较简单,只需要读取图像文件的像素点,然后重新计算 RGB 值,输出成十六进制即可。音频转换比较复杂,需要读取 MIDI 文件,分离轨道,然后得到每个音符对应的频率和时间,计算后再存入十六进制中。资源的准备都是通过 Python 脚本来完成的。

参考链接

  1. Basys 3 Reference
  2. Yoshi’s Nightmare (an FPGA based game)
  3. NES (FC) Picture Processing Unit (PPU) hardware behaviours
  4. Split Scrolling
  5. Audio output
  6. MIDI timing

  1. 项目作为课程设计完成于 2018 年 6 月,本文写作(2018 年 11 月)基于当时提交的项目报告:FlapPGA_Mario.pdf

  2. 当时音乐处理使用了 mido 库,非常不方便,各种信息需要手动计算,后来发现了 pretty_midi 处理起来方便很多,推荐使用这个库。