首发公众号:Rand_cs
PPU
本文继续讲述 NES 的基本原理,承接上文的 CPU,本文来讲述 PPU,较为复杂,慢慢来看。例子基本都是使用的魂斗罗,看完本文相信对那问题“为什么魂斗罗只有128KB却可以实现那么长的剧情”有一定答案。废话不多说,直接来看,先是 PPU 的地址空间部分。
地址空间
PPU 有自己的总线,再来看看这张图:
可以看出通过 PPU 总线能够访问到的地址空间主要由三部分组成:PatternTable, NameTable, Pallete,其中 PatterTable 是映射到卡带里的 CHR
这里说明一下,关于上图前两篇文章有错,PatterTable 是在 NameTable 前面的,这里更正一下,公众号似乎不支持修改图片,我在前文标注一下吧。
有着大致概念之后,来看具体的空间布局,关于这,任天堂给出的图还是很清晰的:
看了前文,应该会对这些名词比较熟悉,前文也简要介绍了这些 Tables,下面就来详细的看看:
PatterTable
PatternTable,映射到卡带的 CHR,CHR 可能是 ROM,也可能是个 RAM。
PatternTable,中文名叫图案表,故名思意,里面存放的是游戏使用到的图案,一个图案称为一个 tile。NES 游戏不论背景还是角色等精灵都是由一个个的 tile 组成。一个 tile $8 \times8$ 像素,NES 游戏屏幕大小为 $256 \times 240$ 像素,所以由 $32 \times 30$ 个 tile 组成(这里相当于算的是背景由这么多个 tile 组成,精灵的话要另算)。
由上图可以看出,有两个图案表,一个图案表 4KB,图案表总共占 8KB。但其实一个 NES 游戏的图案表可能不止两个,总大小可能会超过 8KB,但 PPU 对于 PatternTable 这部分能寻址到的就只有 8KB,所以在某一时段,PPU 最多也就支持 2 个 PatternTable,那我想用其他的 PatternTable 怎么办?这就是前文简要提到过的 Mapper 来干这事了,游戏代码逻辑控制 Mapper 映射当前的地址空间映射到 CHR 里的哪一个 PatterTable
这里我们不讨论那么多,就当 2 个 PatternTable 来讲解说明。PatternTable 里面存放的到底是什么?存放的就是一个个图案 tile,看个例子,emmm 前面举了太多的超级马里奥的例子,这里来看个魂斗罗的例子:
这里不要关注颜色,颜色是我专门这么设置的,感官上要容易辨认一些。这些图案看着还挺清楚像那么一回事是吧(这是 $8 \times 16$ 模式下的样子,这模式后面讲述精灵的时候详述,主要是这种模式下 PatternTable 好辨认些)。
网传魂斗罗这游戏有 128KB,我这上面 .nes 格式的文件有 129KB,也都差不多,相对于一般的 NES 有些来说算是大游戏了,它的 PatterTable 就有多个,这里我只是截取的游戏开始那一段使用的图案表。
由上可以看出一个 PatternTable 是一个个图案也就是 tile 组成,一共由 256 个 tile 组成,这里我们可以简单计算一下:
一个 PatternTable 4KB,那么一个 tile 就是 $4KB / 256 = 16B$
一个 tile $8 \times 8 = 64 像素$,一个像素使用 $16B / 64 = 2bit$
这 2bit 信息是什么?这 2bit 就是颜色信息的一部分,它们来构成了图案。在 $8 \times 8$ 的像素中,通过“点亮”某些像素,“熄灭”某些像素,就可以形成图案,而这里所谓的“点亮”和“熄灭”就是通过赋予该像素 (00, 01, 10, 11) 来实现的,举个例子,GAME OVER 的 O 这个 tile:
这个图也是根据 FCEUX 这个模拟器截出来的,是没有上色的灰阶图像,可以看出,"O" 这个 tile 在 PatterTable 中的索引为 \$03,地址为:\$0030。
换个更本质一点的数字版本:
这就是 tile 的本质,”64 个 2bit“ 信息,仔细看的话,这 2bit 其实是当前这个 tile 使用的 Pallete 中 4 种颜色的索引(对照上图和上上图来看,后面 Pallete 部分还会详述)。
之所以将 ”64 个 2bit“ 打上引号,是因为实际存储时,将这 2bit 的高位和低位分开存储,先存储 64 个像素的低位,再存储 64 个像素的高位。
PatternTable 就先说到这,下面来看 NameTable。
NameTable
NameTable 是背景使用的,背景占据整个屏幕,也就是说背景由 $256 \times 240$ 个像素 $32 \times 30$ 个 tile 组成,这 $32 \times 30$ 个 tile 得有一定的次序排列,才能组成正确的背景画面,而 NameTable 就是记录这个次序的。比如说第一个 tile 槽使用哪个 tile,第二个 tile 槽使用哪个 tile,NameTable 就记录这个信息,记录 $32 \times 30$ 个 tile 槽的索引。
这里又来简单的计算一下:一个 PatternTable 里面有 256 个 tile,索引 256 个 tile 需要 $8bit = 1B$,一屏有 $32 \times 30 = 960$ 个 tile,所以 一个 NameTable 就需要 960B,这与 PPU 的地址空间布局图是一致的。
由那张 PPU 的地址空间布局图可以看出,PPU 支持 4 个 NameTable 的,但是实际物理上只支持 2 个 NameTable。这部分空间是位于 PPU 内部的,是 PPU 的内存,对比现在的显卡,这部分空间就是现存 VRAM,一共 2KB,它的大小只够支持 2 个物理 NameTable。但是有的卡带和其 Mapper 支持超过 2 个 NameTable,道理同上 PatternTable,多出来的这部分空间位于 卡带,由 Mapper 控制映射。这里我们不讨论那么复杂的情况,这里就讨论 PPU 原生支持的 2 个物理 NameTable / 4 个逻辑 NameTable。
这 4 个 NameTable 的逻辑布局如下图所示:
第一个 NameTable 位于左上角,第二个 NameTable 位于右上角,第三个 NameTable 位于左下角,第四个 NameTable 位于右下角。
每个 NameTable 就能存放一屏的 tile 索引,但实际上游戏预先存放了 2 屏的数据,另外两个 NameTable 就是镜像,镜像分为多种方式。
Horizontal
Horizontal Mirroring,水平镜像,示意图如下:
这种镜像是 1、2 NameTable 一样,3、4 NameTable 一样,左右一样,所以叫做水平镜像。使用这种镜像的一般是上下移动的游戏,常见就是 雷电、兵锋这类游戏,比如说 兵锋 刚开始时的 4 个 NameTable 如下所示:
Vertical
Vertical Mirroring,垂直镜像,图示如下:
道理同上,我就不具体解释了,一般左右移动的游戏使用这种镜像,比如我们熟悉的超级马里奥:
Single-Screen
Single 镜像,4 个 NameTable 是一样的东西,我没有仔细的去找使用这类镜像的游戏,而且 FCUEX 这个模拟器似乎不支持 Single 镜像。按我想的,使用这类镜像的游戏应该像是 大金刚 这类只有固定一屏的游戏,我看了下 大金刚 的 NameTable,的确只有 1 个 NameTable 有效,但却是水平镜像,另外的 NameTable 填充的无效数据并没有使用,如下所示:
Four-Screen
四屏镜像(感觉都不叫镜像了),示意图如下
从名字从示意图来看就知道,这 4 个 NameTable 都不相同,前面曾说,物理上 PPU 的 VRAM 只支持 2 个 NameTable,也就是说最多只有 2 个 NameTable 不同,那这里有 4 个 NameTable 都不同,那么需要额外的空间,这部分空间位于 卡带,同样的如前所述,只有部分游戏和卡带以及相应的 Mapper 才支持 Four-Screen 镜像。
使用这类镜像的游戏我没玩过,也就不拿具体的例子来说明了,资料显示有 公路之星2,圣铠传说 等游戏(emmm,听都没怎么听过)
那魂斗罗呢?魂斗罗是主要是横板游戏,使用的是垂直镜像,就算像是第三关上下移动,但也还是垂直镜像,只是同 大金刚 一样,只有一个 NameTable 有效:
这是魂斗罗第三关使用的 NameTable,垂直镜像,所以上下是相同的,但是右侧的两个 NameTable 并未使用,所以实际上只有 1 个 NameTable 有效。
NameTable 就先说到这,记住它是一屏背景 960 个 tile 的索引就行了,下面来看 AttributeTable。
AttributeTable
PPU 地址空间能够寻址到的内存有 2KB,支持 2 个 NameTable,花去 $960 \times 2B$,还剩下 $2KB - 960 \times 2 = 64 \times 2B$,简单来说 一个 960B 的 NameTable 搭配一个 64B 的 AtrributeTable。如果说 NameTable 是记录 960 个 tile 的位置关系,那么相应的 AtrributeTable 就记录这 960 个 tile 的一部分颜色信息,注意是一部分颜色信息(还有一部分是前面的 PatterTable)。
64B 要记录 $256 \times 240$ 个像素 $32 \times 30$ 个 tile 的颜色信息,虽然只有部分颜色信息,但听起来仍是有些不可思议,来看 NES 是如何做的:
每 $4 \times 4$ 的 tile 使用 1B,将这 $1B = 8bit$ 再分成 4 份,每 $2\times2$ tile 分得 2bit。
所以 2bit 就控制着 $2 \times 2 = 4$ 个 tile,$16 \times 16$ 大小的像素区域,挺抠门的是吧,节约到极致,至于这 2bit 信息表示什么以及 PatternTable 那 2bit 表示什么后面 Pallete 再说。这里记住 64B 的 AtrributeTable 管理着整个 NameTable 的一部分颜色信息,其 2bit 就控制着 $2 \times 2 = 4$ 个 tile 的颜色信息。
NameTable 和 AtrributeTable 这两者是一一对应的,一个 NameTable 加上 一个 AtrributeTable 占据 1KB 的内存,PPU 的 VRAM 有 2KB,所以物理上支持 2 个这样的组合。
PPU 关于这部分的地址空间是从 \$2000-\$3FFF,总共 8KB,4 个逻辑上的 NameTable 和 AtrributeTable 加起来也才 4KB,后面的 4KB 是前面的镜像。
关于 NameTable 和 AttributeTable 就先说这么多,下面来看 Pallete 部分。
Pallete
Pallete 调色板,不同的 PPU 芯片使用的调色板可能不同,大体上说都是这个样子:
理论上可以总共可以使用 64 种颜色,但是从上图可以看出明显有些颜色是一样的,所以少于 64 种。这里再次说明,不同的 PPU 支持不同,这里说的一般情况。
怎么表示这些颜色呢?这就分颜色系统了,我们最熟悉的就是 RGB 颜色系统,R G B 分别表示红绿蓝三个颜色通道,每个分量的取值为 [0, 255],比如说 00 号颜色的 RGB 表示为 (84, 84, 84)。
由 PPU 地址空间布局图可知,Pallete 区域为 \$3F00-\$3F20,其后的空间是这部分区域的镜像。
存放在这部分空间的并不是颜色本身,比如说用 RGB 来表示颜色,这部分区域并不是存放 (R, G, B) 三元组,而是存放的是颜色索引 \$00,\$01 等等。
关于颜色部分抠门的地方来了,上面那个调色板可以看作是总的调色板,但实际使用的是 8 个子调色板,每个子调色板包括总调色板中的 4 种颜色。便于叙述后面我就称这子调色板为 Pallete。
1 个 Pallete 包括 4 种颜色,其实是包括 4 种颜色索引,“64” 种颜色只需要 5bit 来索引,但这里索引用 $8bit = 1B$ 来表示,还是很大方的没有像其他地方那么“抠门”。
8 个 Pallete,需要 $8 \times 4 = 32B$,刚好与 \$3F00-\$3F20 对应。
抠门的地方又来了:这 8 个 Pallete 前四个是给背景使用的,对应 \$3F00-\$3F10,后四个给精灵使用,对应 \$3F10-\$3F20。
抠门的地方又又来了,每个 Pallete 的第 0 个(我习惯上将索引从0开始的起始元素叫做第 0 个)颜色是相同的,对于背景来说,这个颜色是通用的背景色,对于精灵来说,这个颜色就是透明色不渲染。
所以对于背景来说,可以使用 $4 \times 3 + 1 = 13$ 种颜色,对于精灵来说 $4 \times 3 = 12$ 种颜色。
比如魂斗罗刚开始时使用的 8 个调色板:
这里多说一句,\$3F00——背景第 0 个 Pallete 的 00 号颜色存放通用背景色,\$3F04/\$3F08/\$3F0C 分别为背景第 1/2/3 个 Pallete 的 00 号颜色,它们可以存放不同的颜色,但是 PPU 渲染的时候通常不会使用它们,而是直接使用 \$3F00 中的通用背景色。\$3F10/\$3F14/\$3F18/\$3F1C 分别是精灵第 0/1/2/3 个 Pallete 的 00 号颜色,它们是 \$3F00/\$3F04/\$3F08/\$3F0C 的镜像。所以说其实一般情况下,这 8 个 Pallete 的 00 号颜色其实是一个色。
讲到这里,就可以来解决上述遗留的问题,对于某个 tile 来说,PatternTable 中记录的 2bit 和 AttributeTable 中记录的 2bit 分别表示什么。
AttributeTable 中记录的 2bit 表示某个 tile 使用哪个 Pallete,因为背景使用的 Pallete 只有 4 个,2bit 索引就足矣,PatternTable 中记录的 2bit 表示 tile 中的某像素使用这个 Pallete 中的哪个颜色,因为 Pallete 中有 “4” 种颜色,2bit 索引足矣。
这里还可以得到这样一个事实:AttributeTable 中 2bit 控制 $2 \times 2$ 个 tile 使用的 Pallete,所以一个 tile,或者说相邻的 4 个 tile 最多就只能使用 4 种颜色。
这是魂斗罗开始场景的截图,每个蓝色方格代表 $2 \times 2$ 个 tile,大致还是可以看出,每个蓝色方格里最多就使用了 4 种颜色。
OAM
前面讲述的 PatternTable, NameTable, AttributeTable, Pallete 都是 PPU 地址空间中可寻到的。PPU 还有一部分较大的内部的内存,并没有包含在 PPU 地址总线可寻到的地址空间里面。
这部分内存叫做 OAM,Object Attribute Memory,用作精灵的属性信息。这里的 Attribute 并非上述讲的 Attribute,这里的 Attribute 含义更加广泛。
OAM 总共 256B,支持 64 个精灵条目,也就是说每个精灵条目有 4B 的信息。这 4 字节,每个字节都有不同的含义:
Byte 0
第 0 个字节记录该精灵的 Y 坐标,以像素为单位,坐标示意图如下
Byte 1
tile 索引,表示该精灵使用的是哪个 tile
关于精灵使用的 tile 有两种模式,一种是 $8 \times 8$ 模式,一种是 $8 \times 16$ 模式,这由寄存器($2000) 控制。
顾名思义,$8 \times 8$ 模式的精灵宽 8 高 8,1 个 tile 组成,而 $8 \times 16$ 模式的精灵宽 8 高 16 实际上由 2 个 tile 组成。
对于 $8 \times 8$ 的精灵来说,Byte1 存储的 tile 索引就是该 tile 的索引。对于 $8 \times 16$ 的精灵来说,Byte1 存储的 tile 索引为该精灵上侧的 tile 索引,而下侧的 tile 索引为 $Byte1 + 1$。
来看个例子,来源于魂斗罗偶数关卡角色的一部分:
Byte 2
精灵的属性:
- bit0-1:该精灵使用的 Pallete
- bit2-4:未使用
- bit5:精灵与背景的优先级,0 表示该精灵在背景前面,1 表示该精灵在背景后面。
- bit6:水平翻转
- bit7:垂直翻转
使用哪个 Pallete 道理同背景,这里的 2bit 是在精灵的 4 个 Pallete 中索引。
精灵优先级在后面渲染的时候再说明,这里也可简单说一下:当精灵与背景重叠时,那么应该是渲染精灵的像素还是背景的像素呢?当两者的颜色都不是(透明色/通用背景色,也就是说颜色索引不是00) 时,如果精灵有背景前的优先级,那么渲染精灵的像素,如果精灵使背景后的优先级,那么渲染背景的像素。
翻转也是前面所说的抠门之一,有些 tile 只要翻转一下就可以当作另一个 tile 使用。在魂斗罗第二关有种跳来跳去的敌人:
可以看出它的下半身是对称的,这两个精灵信息如下:
可以看出 PatternTable 中 实际上并没有 “左腿” tile,只有 “右腿” 的,将 “右腿” 翻转一下作为 “左腿” 使用,所以这两个精灵其实都是索引为 \$C2 和 \$C3 的 tile 组成的。
至于垂直翻转也是同样的道理,具体的例子一时没找出来,就不举例说明了。
Byte 3
该精灵的 X 坐标,以像素为单位,道理同 Byte 0 不再多说。
由前文可知,视频都是一帧一帧的画面组成,帧与帧之间的空隙叫做 V_Blank,这段时间一开始 PPU 就触发 NMI 中断,然后 CPU 执行 NMI 中断服务程序。通常就是这个时间段将更新好的精灵信息搬运到 PPU 的 OAM 当中。CPU 的 RAM \$200-\$2FF 这 256 字节通常存放的是精灵信息,CPU 运行游戏代码对这 256 字节的精灵信息更新,然后在 V_Blank 期间将其搬运到 PPU 的 OAM 中。所以说每一帧画面最多支持 64 个精灵。
另外这里讲述的 OAM 全名叫做 Primary OAM Memory,还有 Secondary OAM Memory,实际上就是 Primary OAM Memory 的一个缓存,其大小支持 8 个精灵信息。前面我们说过,一帧画面有 240 条可见的 Scanline,这 Secondary OAM Memory 就是用来存放当前 Scanline 将要渲染的精灵信息。所以说一条 Scanline 上最多只有 8 个精灵。
本文就先说到这吧,也算是对魂斗罗有了个简单分析,这篇只是讲述了 PPU 关于内存的一部分,对于它的寄存器,如何滚屏,渲染等等还未讲述,涉及到 PPU 一些硬件,留待后面讲述。
了解到这其实可以进行简单地 NES 程序开发了,只不过关于 PPU 的内存如何访问,CPU 和 PPU 如何交互信息,比如如何搬运 OAM 数据等等都未讲述,emmm 我后面闲得话再讲述吧。
好了本文就到这里,有什么还请批评指正,也欢迎大家来同我讨论交流。
最后没事睡不着吐点槽,南北方差异真的大啊(主要是没暖气一时不习惯),我好像每次过年回家都会整感冒,特别这次从高风险地区回来,提前走的,刚走 2 天就爆发疫情,幸好提前走的,不然就回不了家就地过年了。然后进行居家隔离,话说刚好 14 天开始出现“症状”,感冒了,虽然核算检测不是那玩意儿,但太 TMD 巧了,刚好这时间点,而且还经都好不了,这就有点搞心态啊。
所以大家还是要多保重身体,身体至上,像我就是反面教材,现下凌晨 3 点,这么晚了就应该睡觉了,而我还在劈里啪啦的打字写文章,所以...所以...不来个赞和在看?
首发公众号:Rand_cs