童年神机小霸王原理(四) 滚屏渲染 1

1,453 阅读14分钟
  • 首发公号:Rand_cs,求关注支持

滚屏渲染(基础部分)

本文继续 PPU 的话题来讲述滚屏,从我们小时候玩游戏的经验知道 NES 是支持像素级滚屏的,这在当时那个年代是个创举,这也是为什么 FC/NES 那么火热的原因之一

那 PPU 是如何支持像素级的滚屏?这就要先来看看 PPU 的一些硬件部分。

内存映射寄存器

首先来看看映射到 CPU 地址空间的一些寄存器,也是 CPU 与 PPU 通信的端口。从本文开始十六进制数我还是用 0x 表示,用 $ 有太多的格式问题,前文我每个 $ 前面加上了 \ 转义,然后使用 mdnice 导格式始终有问题,所以干脆直接就使用 0x 算了,大家也还熟悉些,只是如果要对 nes 汇编开发的话十六进制数得使用 $,不过估计几乎也没人干这事。不多说了,来看 PPU 的寄存器:

PPUCTRL

控制寄存器(0x2000):

  • bit0-1,选取 NameTable,00-0x2000, 01-0x2400, 10-0x2800, 11-0x2C00
  • bit2,有个专门的寄存器记录访问 VRAM 时的地址,每次访问现存 VRAM,这个值都要增长,0:增长 1 即水平移动,1:增长 32 即纵向移动
  • bit3,精灵使用哪个 PatternTable,0:0x0000, 1:0x1000
  • bit4,背景使用哪个 PatternTable,0:0x0000, 1:0x1000
  • bit5,精灵大小,0:8×88\times8,1:8×168\times16
  • bit7,在 V_Blank 开始的时候产生 NMI

基本上前文都说过,详细的情况后面也还会说到。

PPUMASK

mask 都知道啥意思,要屏蔽什么东西,所以这个寄存器就是来控制哪些渲染哪些不渲染:

  • bit0,0:显示正常颜色,1:显示黑白图像
  • bit1,1:渲染背景最左侧 8 列像素,0:不渲染
  • bit2,当精灵位于屏幕最左侧时,1:渲染精灵左侧 8 列像素,0:不渲染
  • bit3,1:渲染背景,0:不渲染
  • bit4,1:渲染精灵,0:不渲染

PPUSTATUS

状态寄存器,主要记录 3 个状态:

bit5:精灵是否溢出,精灵溢出是只当前扫描行有没有超过 8 个精灵,超过则该位置 1,表溢出

bit6:sprite 0 hit,当 sprite 0 的不透明像素与背景不透明像素重叠时该位置 1,这个主要用于屏幕分割,就是制造那大片级的效果

bit7:是否处于 V_Blank,是的话置 1

OAMADDR & OAMDATA & DMA

看到像是 addr 和 data 寄存器,大概就知道是是用 addr 来选取一个地址,然后从 data 寄存器对该地址上的内容进行读写。的确如此,这两个端口就是用来操作 OAM 这片空间的。这里要注意因为地址总线有 16 位,而数据只有 8 位,所以每次对地址相关信息读写时要连续操作 2 次

However,一般不这样使用,因为每次传输数据经过 CPU,太慢,通常是 OAMADDR、OAMDMA(0x4014) 两个端口配合使用。DMA 大家应该很熟悉,这里一样的道理,只要将 CPU 地址空间中的精灵信息首地址(通常是 0x200)的高低 8 位 分别填入 ADDR 和 DMA 中,DMA 就会自动将 CPU 地址空间中的精灵信息加载到 OAM,不用每次经过 CPU 中转速度大大加快

而且大多数时间都不应该更改 OAM 里面的内容,通常情况下 OAM 里面的内容只应在 V_Blank 期间更改,因为其他时间段都处于渲染阶段,比如说当前帧渲染刚开始时精灵在地上,当前帧渲染要结束时精灵跑天上去了,这明显不合理是吧。

Scroll

滚屏寄存器,只写,连续写两次来决定哪一个像素位于屏幕左上角。举个例子直观了解,这是马里奥的两个 NameTable:

上图一个小格就是 8 个像素,如果我向 Scroll 先后写入 24,16,则会从下图所示位置开始渲染:

ADDRESS&DATA

PPUADDR 寄存器地址 0x2006,PPUDATA 寄存器地址 0x2007,同样的还是 addr/data 的方式读写内存。只不过这里的内存是 PPU 地址空间的内存,也就是说可以通过这两个寄存器访存 PPU RAM,PatternTable,Pallete,其他的没啥说的,基本一样。

内部寄存器

这一部分讲述 PPU 内部不可见的寄存器,前面那几个 内存映射的寄存器 我们是可以操作访问的,但是下面这几个寄存器是不能直接访问的,来看:

v

Currrent VRAM address,15 bit,即 v 里面存放的是当前访问要 VRAM 的地址

t

Temporary VRAM address,15 bit,临时存放要访问的 VRAM 地址,或者存放滚屏地址,关于这后面详细解释。

x

fine X Scroll,3bit 存放滚屏时 x 轴方向的细致地址,关于滚屏后面详细说明。

w

toggle,1bit,一个开关,因为地址有 16bit,数据总线只有 8bit,所以写地址需要连续写两次,因此需要一个 toggle 来记录是第一次写还是第二次写。

滚屏简析

滚屏前面在 Scroll 寄存器的地方说过一点,这里稍微详细地解释一下,也是解释内存映射寄存器和其内部的寄存器的关系。

前面我们说过向 Scroll 寄存器连续写两次(X 地址和 Y 地址)就可以设定哪一个 NameTable 的哪一个像素位于屏幕的左上角。虽然 NameTable 实际上是存放着一屏 tile 的索引,但是我们从逻辑上可以看作就是一屏 tile。

其中设定哪一个 NameTable 是通过写 0x2000 PPUCTRL 寄存器的低 2bit

而 X 地址可以分为 coarse X 和 fine X,简单的翻一下就是粗糙的 X 地址和细致的 X 地址(有啥好的翻译??),Y 地址同样也是如此,可以分为 coarse Y 和 fine Y,什么意思呢,直接来看图:

还是很好理解吧,coarse 表示某个 tile 的坐标,fine 表示这个 tile 内某个像素的精确位置

而这与 t v x t 啥关系呢?

如果 t,v 表示滚屏地址的话,它们有如下的结构:

图示很清晰不再解释,只是这里少了 fine X scroll,fine X 单独存放在 x 寄存器里面。

向 0x2000 写的数据低 2 bit 写进 t 的相应位置,表示使用哪个 NameTable

当 w = 0 即第一次向 Scroll 寄存器写时,X 地址的高 5 位写进 t 的低 5 位,数据低 3 位写进 x,写后将 w 置 1 表示下一次写将是第二次写。

当 w = 1 即第二次向 Scroll 寄存器写时,Y 地址直接写进 t 的相应位置,写后将 w 清 0.

上述操作就可以设置 某个 NameTable 的某像素位于屏幕左上角,一般情况是在 V_Blank 期间也就是 CPU 处理 NMI 的时候设置,每次使其加 1,就可以实现横向滚屏

从编程人员的角度来说,这就是滚屏,再来总结一番:向 0x2000 低 2 位写入 NameTable,连续向 0x2005 写两次 X、Y 选取某个像素位于左上角,每次 V_Blank 期间设置一次就可以实现滚屏

这只是一般情况下的简单滚屏方式,有一些高级玩法屏幕分割技术后面再说,另外这也只是从编程人员的角度理解,硬件怎么做的渲染部分详述。

硬件抠门部分

前面说过 NES 很多抠门的地方,不过都是软件部分,这里来说说硬件部分抠门的部分。

向 0x2005 写入数据实际上就是写入 t,向 0x2006 写入地址实际上也是写入 t,只不过最后再从 t 复制到 v。地址 16 位同样需要写 2 次,所以需要一个 toggle 来记录到底是第几次写,而这个 toggle 也是共用上面提到的 w

也就是说可以认为向 0x2005 和 0x2006 写入数据时,实际上共用两个寄存器 t 和 w,下面详细说说:

向 0x2006 第一次写入高地址时,只有数据的低 6 位有效,t 的最高位是清 0 的,另外 w 置 1。

向 0x2006 第二次写入低地址,数据的 8 位全都有效,将其写到 t 的低 8 位,写完立即将 t 复制一份 到 v,这就是写 0x2005 和 写 0x2006 的区别。写完 0x2005 后不会从 t 复制到 v,而写 0x2006 需要。另外写完之后都是需要将 w 清 0 的

另外不论是读还是写 VRAM,都会使得 v 中的值自动加 1 或 32,这由 PPUCTRL 寄存器 bit2 控制,加 1 表示横向下一个 tile,加 32 表示纵向下一个 tile

这部分的最后好好捋捋两个地址,一是向 0x2005 写入的滚屏地址,二是向 0x2006 写入的普通地址。

普通地址就没什么说的,它是 PPU 地址空间的地址,但是 PPU 地址空间有 64KB,但是有用的只有 8KB,所以其实 14 位就足够了,因此第一次写 0x2006 高位字节时只有 低 6 位有效

而向写 0x2005 写的滚屏地址,严格意义上来说不能算是地址,t 与 x 加起来算是某个像素的位置。

明显的看这个图,怎么都不想一个地址的格式,一个地址也不可能这么分割。但是,t 的低 12 位,也就是 NNYYYYYXXXXX 确实可以看作一个地址。

12bit 可以索引 4KB,刚好是 4 个 NameTable & AttributeTable 的大小,而地址划分的格式刚好就是 NNYYYYYXXXXX,NN 选取 NameTable,YYYYY 表示 tile 的 Y 坐标,XXXXX 表示 tile X 坐标

当然这里的 12 位地址不是绝对地址,而是相对于 0x2000 的相对地址

渲染

渲染就分两部分,背景渲染和精灵渲染,以像素为单位渲染。PPU 的 “每个时钟周期” 获取背景的颜色信息和精灵的颜色信息,两者优先级竞争决定输出哪个

很粗浅的解释,要弄清楚还是得来了解 PPU 内部的一些硬件:

背景

  • 首先是前面提到过的 VRAM address,temporary VRAM address,Scroll,toggle

  • 2 个 16bit 移位寄存器,后面我称作 pattern_shifter,这 2 个寄存器存放将要渲染的 2 个 tile,这里要清楚 tile 是高低位分开存放的,所以一个寄存器存放 2 个 tile 的高位,一个寄存器存放 2 个 tile 的低位。一个 tile 图案是 64 个像素,128 位信息,因为是一行一行的渲染,只用存放一行的 tile 信息,所以 shifter 16 位就足够了

  • 2 个 8bit 移位寄存器,后面我称作 attribute_shifter,这 2 个寄存器存放相应的 Atrribute 信息。

下面来详细说明这些硬件在渲染期间的作用:

前面说过,渲染的方式是一个像素一个像素的渲染,且走的是 Z 字型。对于一般的 NTSC 系统来说有 262 条 Scanline,其中有 240 条 Scanline 可见,每条 Scanline 持续 341 个时钟周期,这期间就是不停的取数据然后输出渲染。这里我们先不说明每条 Scanline,每个时钟周期干什么,先来了解背景总体的渲染过程。

渲染一个背景像素需要 4bit 的颜色信息,渲染过程其实就是取得这 4bit 颜色信息。如何取得呢?

PPU 会从 v 中获取该像素所在的 tile 索引的地址信息,将这个 tile 取过来分高低位存放到 pattern_shifter 寄存器当中。然后取该 tile 的 attribute 信息分高低位存放到 attribute_shifter 寄存器当中,如此一个像素的 4 bit 颜色信息就齐了

在每条 Scanline 的前 256 个周期,每个周期 shifter 寄存器左移 1 位,每 8 个周期就加载下一个 tile 信息到 shifter 寄存器,之后根据 fine_x 选出当前要渲染的像素,举个例子说明:

上图将 0x2005 设置滚屏地址,shifter 寄存器联系起来了,像是 attribute_shifter 也是类似的操作,图上有说明,应该能看懂什么意思的,我就不详细解释了,另外这些细节 wiki 上其实并没有明说,这是我根据模拟器的源码推出来的,这方面似乎应该也没什么详细的手册资料吧,如果有错还请指出。

可能有朋友有疑问,为什么 v 中存放着该像素所在的 tile 地址信息,这个问题其实与为什么向 0x2005 连续写两次就可以选取某个 NameTable 的某个像素位于屏幕左上角相似。

当我们向 0x2005 写两次,其实就是将某个 NameTable 的某个像素地址写入了 t,在渲染期间 t 会被复制到 v(这里我们再后文会讲述),所以写 0x2005 后第一次用 v 中的地址信息取得的 tile 就是我们所设定的,那么就使其位于屏幕左上角之后每次使用 v 中的地址读取 tile 索引的地址信息都会自动加 1 指向下一个 tile,如此循环往复渲染 960 个 tile,一帧背景

背景的渲染总过程就先说到这儿,一句话总结,根据 v 中记录的 tile 地址从 PatternTable 中取得 2bit 颜色信息到 pattern_shifter 寄存器,然后从 AttributeTable 中又取得 2bit 颜色信息到 attribute_shifter 寄存器,最后根据 fine_x 从 shifter 寄存器中选取要渲染的像素颜色信息

精灵

对于精灵来说,有这些相关硬件

  • Primary OAM,前文说过,256 字节,每一帧支持 64 个精灵
  • Secondary OAM,当前正渲染的扫描行支持的 8 个精灵
  • 8 对 8bit 移位寄存器,存放当前正渲染的扫描行上的精灵 tile
  • 8 个 锁存器,存放 8 个精灵相应的 Attribute
  • 8 个 计数器,记录 8 个精灵的 X 坐标值

存放 tile 图案信息到 pattern_shifter 和 attribute 信息到锁存器道理同背景,只是换了个名字锁存器其他的基本没啥不同,也不需要了解那么深入,有兴趣的可以在我后台回复 NES 获取 PPU 的手册。

这里主要说说计数器有什么作用,渲染是一行一行的渲染,每行像素的 x 坐标值范围为 [0, 255],存放在计数器中的 X 坐标每个周期是会减 1 的,所以说,当某个计数器减到 0 时说明渲染到该精灵了。

而对于精灵渲染总体过程与背景大致相同,主要是取得一个像素的 4bit 颜色信息,只是 shifter 寄存器只有等到计数器为 0 的时候才会活动(每个周期左移)。

取数据到 shifter 需要地址,这个地址就不是在 v 里面了,而是在精灵条目 OAM 中(正渲染的时候是在 Primary OAM 当中),从这里面取得 tile 索引的地址之后就去获取 tile 图案信息存放到 pattern_shifter 寄存器当中,然后获取 attribute 信息就简单了,直接从 OAM 当中获取。

好了现在我们精灵的 4bit 颜色信息和背景的 4bit 颜色信息都有了,然后就竞争到底输出哪个,当然只有背景和精灵重合的时候会有竞争,方式如下:

如果只有背景,输出背景

如果背景像素和精灵像素重合:

数字表示使用的 Pallete 中的哪个颜色,0 号颜色不管背景还是精灵都是相同的,对于背景来说可以看作是通用的背景色,对于精灵来说就是透明色。而 priority 是精灵条目中的一个属性位

好了本文就先说这么多,本文主要讲述了内存映射的几个寄存器和内部的几个寄存器,另外简析了滚屏和渲染,后文讲述渲染每个周期的细节,以及一些关于滚屏的高级玩法。

  • 首发公号:Rand_cs,求关注支持