NES红白机模拟器实现 (三) 帧的合成原理

前文: Froser:NES红白机模拟器实现 (二) 6502 CPU与程序的机器级表示

PPU全称是Picture Processing Unit,图像处理单元,用于生成图像。在早期没有GPU的时代,PPU的作用是生成一条条的扫面线,投射到外接的纯平显示器上。
红白机使用的是2C02 PPU,此PPU是不可编程的,它通过电路预制好了渲染逻辑,从自己的内存中以一定方式取出数据,生成信号。
在数据存储非常昂贵的年代,PPU设计了一套十分“拮据”的数据结构和固定流水线。
红白机的游戏画面像素为240*256,PPU的每一个周期都会生成一个像素,且它的频率是CPU的3倍。此外,除了生成240*256个像素外,它还有许多额外的周期,不生成可见像素。在这段周期CPU可以对渲染数据进行修改。由于PPU是按照扫描线逐个像素逐行生成画面的,因此在第240行可见行生成完毕后,将进入VBlank阶段,VBlank阶段可以认为是扫描线从电视机最下面到第一行的一个阶段,此阶段CPU更新PPU中的信息不会影响到渲染的结果,事实上CPU不应该在VBlank之前改动PPU的数据,否则会造成画面割裂(除非你知道你在做什么)。
用程序开发的术语来讲,PPU定义了如下数据结构:

  • 样式表 (Pattern Table)
  • 调色盘 (Palette)
  • 名字表 (Nametable)
  • 属性表 (Attribute)
  • OAM

另外,PPU内置了许多寄存器来控制渲染结果,例如,控制画面的滚动等。


样式表 (Pattern Table)


Pattern table中存放着游戏用到的所有图元(tile)。通常来说,它只能被PPU访问,它被存储在游戏卡带的CHR RAM中。
卡带中有两个Pattern table,分别映射到了PPU的$0000-$0FFF和$1000-$1FFF中:

Address rangeSizeDescription
$0000-$0FFF$1,000Pattern table 0
$1000-$1FFF$1,000Pattern table 1


上图中左边部分就是Pattern table 0,右边部分就是 Pattern table 1。样式表就是游戏最基本的单位:图元。无论是背景,还是游戏的精灵(Sprite),都来自于Pattern table。
虽然上图中看上去的效果是绿色的,但是Pattern table中记录的只是图元的形状信息,而非具体的颜色。它为每一个像素定义了一个调色板索引,每一个调色板拥有4种颜色,那么像素的调色板索引范围也就是0-3,占用2位。NES中支持8个调色板,如果我们把它们都绘制出来,那么效果就是下面这样的:


数据结构

每一个样式表占用$1000(4kb)字节,它们形成的图案被分成了16*16个图元,每个图元8*8个像素。也就是说,每个样式表表示256个图元,每个图元64个像素。
样式表中每一个8*8像素的图元以16字节表示。每2个bits表示一个像素。
你可能会问为什么不是用8个字节表示一个图元,因为8字节刚好就是64bits,那么刚好可以表示一个黑白的64个像素的图元。答案是因为Pattern table不仅仅需要表示形状,还需表示其颜色,而每一个调色板有4种颜色,用二进制表示就是4种情况:00, 01, 10, 11。我们用前8个字节表示它在调色板第0位的值,后8个字节表示调色板第1位的值,或运算之后则可以得到一个0-3的调色板的索引了。
例如:


假设Pattern table某16字节如上所述,图元的起始地址一定是以十六进制0开头,我们可以看到$0xx0-$xx7表示了图元第0位的颜色,$0xx8-$0xxF表示图元第1位的颜色,将上下进行或运算,我们就可以得到右侧最终的图元形状和颜色了。由于调色板索引0永远表示背景颜色,因此用.来表示。


图元(Tile)的检索

假如我们知道某个PPU的地址,那么我们如何知道它是第几个图元呢?非常简单:

  • 由于图元是16字节对齐的,因此图元的第4-11位(也就是上述例子中的xx)就是图元的编号。
  • 地址的第0-3位表示图元的像素的位置。
  • 地址的第12-15位表示的是哪一个Pattern table(我们说过Pattern table有2个)。

用伪代码表示就是:

int which_tile = (ppu_address >> 4) & 0x00ff;
int which_pattern_table = (ppu_address >> 12) & 0x000f;
int tile_x = which_tile % 16;
int tile_y = which_tile / 16;

实际开发中,我按照如下方式来进行地址的解析:

DCBA98 76543210
---------------
0HRRRR CCCCPTTT
|||||| |||||+++- T: Fine Y offset, the row number within a tile
|||||| ||||+---- P: Bit plane (0: "lower"; 1: "upper")
|||||| ++++----- C: Tile column
||++++---------- R: Tile row
|+-------------- H: Half of pattern table (0: "left"; 1: "right")
+--------------- 0: Pattern table is at $0000-$1FFF

它与上面的解释是等效的。
第0-2位:表示图元的y坐标,即row number
第3位:表示颜色平面,即是调色板的低位还是高位
第4-7位:图元位置x,等效于对16取模
第8-11位:图元位置y,等效于除以16取整
第12位:是哪个pattern table,左侧还是右侧

调色盘 (Palette)

系统调色盘

每种型号的PPU会预定义一组系统调色盘。不同的PPU可能调色盘效果上不一样,但是大致都是一样的:


系统调色盘的编号范围从$00-$3F,它就是红白机中可以显示的所有颜色。


调色盘

调色盘是指从系统调色盘中选出的4种颜色而成的一个颜色组合。其中,这4种颜色中的第0个颜色是背景色。
PPU支持8组调色盘,调色盘中记录的颜色是系统调色盘中的索引。由于第0个颜色是背景色,所有这8组调色盘首个颜色都是一样的。
我们不可以直接使用系统调色盘,而是要先选择8组调色盘中的一组,再指定其颜色索引(0-3),方可以间接使用系统调色盘。
现在可以更好的来理解超级马里奥兄弟1中的Pattern table和调色盘的关系了,我用代码输出了8组调色盘下的Pattern table的图案,以及每个调色盘中的色值和对应的全局调色盘的索引:



每一组调色盘的内存被映射在PPU的$3F00-$3FFF中:

Address rangeSizeDescription
$3F00-$3F1F$20Palette RAM indexes
$3F20-$3FFF$0Mirrors of $3F00-$3F1F


可见,从$3F20开始就是调色盘内存地址的镜像,实际上调色盘只占用了$3FFF-$3F1F$20字节(32字节)。
其中,8组调色盘分为两大部分,一部分是为背景所设置的调色盘(Background palette),一部分是为精灵所设置的调色盘(Sprite palette),其布局如下,其每一个字节指代系统调色盘中某个色值的索引:

AddressPurpose
$3F00Universal background color
$3F01-$3F03Background palette 0
$3F05-$3F07Background palette 1
$3F09-$3F0BBackground palette 2
$3F0D-$3F0FBackground palette 3
$3F11-$3F13Sprite palette 0
$3F15-$3F17Sprite palette 1
$3F19-$3F1BSprite palette 2
$3F1D-$3F1FSprite palette 3


  • $3F00为背景色,也就是所有调色盘的首个颜色。
  • 每个背景调色盘占用3个字节,分别表示各自调色盘1-3号的颜色索引。
  • 每个精灵调色盘占用3个字节,分别表示各自调色盘1-3号的颜色索引。
  • $3F04/$3F08/$3F0C不在渲染时被使用。
  • $3F10/$3F14/$3F18/$3F1C$3F00/$3F04/$3F08/$3F0C的镜像。

调色盘地址的解析

调色盘的范围是$3F00-$3F1F,实际上只有5位被使用到,其含义如下:

43210
|||||
|||++- Pixel value from tile data
|++--- Palette number from attribute table or OAM
+----- Background/Sprite select

第0-1位:调色盘中的色值的索引。
第2-3位:哪一个调色盘
第4位:是背景调色盘还是精灵调色盘。


名字表(Nametable)

PPU渲染出来的画面分辨率为240*256。画面的基本单元为图元(tile),一个图元大小为8*8,也就是说,PPU渲染出来的画面是由30*32个图元所组成的。
Nametable就是存放图元的一块内存,也可以认为就是PPU的显存。PPU拥有4个Nametable,这意味着其实它可以渲染出4个屏幕,这是为了能够平滑滚动画面而特意设计的。我们的视窗,也就是显示器,只有240*256,但是4个Nametable渲染出来的区域为960*1024,所以当视窗在中间移动的时候,视窗边缘区域已经是渲染好的,所以非常流畅。

Nametable内存布局

4个Nametables的内存布局和位置如下所示:

Address rangeSizeDescription
$2000-$23FF$400Nametable 0
$2400-$27FF$400Nametable 1
$2800-$2BFF$400Nametable 2
$2C00-$2FFF$400Nametable 3
(0,0)     (256,0)     (511,0)
       +-----------+-----------+
       |           |           |
       |           |           |
       |   $2000   |   $2400   |
       |           |           |
       |           |           |
(0,240)+-----------+-----------+(511,240)
       |           |           |
       |           |           |
       |   $2800   |   $2C00   |
       |           |           |
       |           |           |
       +-----------+-----------+

我们可以看到,每一个Nametable占用了$400(1kb)大小,它们在PPU中的起始位置分别是$2000,$2400,$2800$2C00
Nametable中的每一个字节都是一个图元的索引,那么实际上一个Nametable需要30*32=960字节。余下的1024-960=64字节,被称为属性表,表示某部分的区域需要用哪个调色板。

Nametable Mirroring

PPU拥有2kb的内存用于存放Nametable,但是4块Nametable需要4kb内存来存放。聪明的你一定想到了,Nametable其实一般是对称的,它要么是左右对称,要么是上下对称,这种镜像模式称为Nametable Mirroring。镜像方式由卡带决定,不同的游戏采取不同的镜像方式。

  1. 竖直镜像 (Vertical Mirroring)

横版闯关游戏采取这种镜像方式,即$2000=$2800, $2400=$2C00。超级马里奥兄弟1采取这样的镜像:使用kiwi提供的调试工具,输入nt可以输出当前帧的nametable。


  1. 水平镜像 (Horizontal Mirroring)

适合上下滚动的游戏,如Ice Climber:


2. 单屏 (Single Screen)

适合不滚动屏幕的游戏,只在特定卡带支持。
3. 四屏(Four Screen)

适合需要上下、左右滚动的游戏,需要特定的卡带支持。例如超级马里奥兄弟3:


四屏游戏突破了NES的2kb PPU内存,需要额外2kb内存来保存Nametable。

属性表 (Attributes table)

在每个Nametable的末尾64字节就是属性表。
属性表用于规定每个tile用哪个调色板。
由于每个Nametable有960个tile,因此属性表中的每个字节需要控制16个tile,也就是一个4*4的tile。


上图中的超级马里奥兄弟的Nametable中,每个蓝色的格子表示一个2*2的tile,每4个蓝色格子受到1个属性表中的一个字节控制:换一句话说,每个4*4的图元中,只能使用同一个调色盘。如不是使用高级的渲染技巧,它不可能超过4种颜色,这也就是为什么红白机游戏看上去色彩如此单一的原因。

属性表的布局

我们把每4*4个tile称为一个block,例如,左上角的($2xC0, $2xx0)就是一个Block。

       2xx0    2xx1    2xx2    2xx3    2xx4    2xx5    2xx6    2xx7
     ,-------+-------+-------+-------+-------+-------+-------+-------.
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xC0:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xC8:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xD0:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xD8:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xE0:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xE8:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xF0:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
2xF8:|   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     `-------+-------+-------+-------+-------+-------+-------+-------'

每一个Block分为左上、右上、左下、右下4个部分,这4个部分分别被属性表中的2位所控制:

7654 3210
|||| ||++- Color bits 3-2 for top left quadrant of this byte
|||| ++--- Color bits 3-2 for top right quadrant of this byte
||++------ Color bits 3-2 for bottom left quadrant of this byte
++-------- Color bits 3-2 for bottom right quadrant of this byte

在实际渲染中,我们先得到某个tile在block中的位置,然后取其对应的属性表的值,接着按照其位置去除其中2位,就可以得到这个tile所用到的调色板了。

实例

nesdev.org/wiki/PPU_att

OAM

OAM即Object Attribute Memory的缩写,它表示的是游戏中的精灵(Sprite)的位置信息,如超级马里奥兄弟中的马里奥、蘑菇、库巴等可以移动的角色。
PPU中内置了256字节,用于存放精灵信息,这块内存区域被称为OAM。
PPU中支持在一帧画面中显示64个精灵,每个精灵占用4个字节。每个精灵为一个tile,所以实际上一个游戏角色可能是由多个tile所组成,例如吃蘑菇之前的小个子马里奥,就是由2*2的Sprite所构成。

OAM的内存布局


Address Low NibbleDescription
$00, $04, $08, $0CSprite Y coordinate
$01, $05, $09, $0DSprite tile #
$02, $06, $0A, $0ESprite attribute
$03, $07, $0B, $0FSprite X coordinate


每4个字节的OAM控制一个Sprite,这是因为Sprite和背景tile不同,它需要一些额外的属性来控制:

  • 背景tile的位置是固定不变的,而Sprite是可以移动的,它需要有一个x坐标和一个y坐标。
  • Sprite是可以镜像的。显然马里奥朝左和朝右用到的是同一个tile,只需要镜像一下,不需要多一个tile而造成浪费。
  • Sprite和背景有层级关系,精灵可以在背景之前,也可以在背景之后(例如某个敌人藏在了某棵树后面)。

因此,这4个字节解析如下:

  1. 字节0

Sprite的Y坐标(0-255)


2. 字节1:Sprite的tile的索引。

PPU支持8*8的Sprite,也支持8*16的长Sprite,它们在获取tile索引时略有不同。

76543210
||||||||
|||||||+- Bank ($0000 or $1000) of tiles
+++++++-- Tile number of top of sprite (0 to 254; bottom half gets the next tile)

3. 字节2:属性,如翻转、层级关系等

76543210
||||||||
||||||++- Palette (4 to 7) of sprite
|||+++--- Unimplemented (read 0)
||+------ Priority (0: in front of background; 1: behind background)
|+------- Flip sprite horizontally
+-------- Flip sprite vertically

4. 字节3:Sprite的x坐标(0-255)

OAM的同步

PPU中OAM的信息有两种方式从CPU中进行同步。

  1. 通过设置OAMADDR和OAMDATA来更新

CPU通过写入$2003(OAMADDR),将某个OAM的地址起始值写入PPU的OAMADDR寄存器。
接着,CPU通过写入$2004(OAMDATA),将数据写入OAMADDR所指向的地址,从而完成某个OAM的更新。
缺点:CPU执行周期非常长,编程复杂,很少这么使用。


2. 使用DMA

DMA是Direct Memory Access的缩写,它允许批量将CPU中的256个字节(也就是一页),直接更新到OAM中。
CPU通过写入$4014(OAMDMA),指定一个内存页,接着经过513-514个CPU时钟周期,这一页中的256个字节将会被拷贝到OAM中。这是最常用的更新Sprite的方式。

Sprite Zero Hit

PPU支持渲染64个Sprite,它们按照地址顺序被编号为0-63。当第0个Sprite被渲染(其首个像素覆盖到了非透明的背景像素)时,被称为Sprite Zero Hit。PPU寄存器PPUSTATUS中的某一位将会被设置,来记录Sprite Zero Hit。

Sprite叠加

多个Sprite被渲染到同一个像素时,编号小的Sprite的非透明像素将会被渲染。
关于Sprite渲染优先级,可以参考nesdev.org/wiki/PPU_spr

Sprite溢出

受限于PPU的流水线,每一行扫描线只能渲染8个Sprite。也就是说,如果某一行扫面线中超过了8个Sprites,那么只有前8个可以被渲染出来,同时PPU的寄存器PPUSTATUS中的某一位会被设置,表明发生了Sprite overflow,这也就是为什么某些游戏(如魂斗罗)中敌人数量过多时画面会闪烁的原因。

画面视窗与滚动

视窗位置的计算

PPU中,可以认为存在2个滚动寄存器,来表示视窗的位置,同时在每一帧开始时,PPU的渲染地址指向了Nametable中的某个地址,即将它左右画面的左上角。
在许多文档中,Nametable中的地址所指向的tile的位置被称为Coarse X和Coarse Y,因为它是一个大致位置,毕竟画面滚动不可能以8像素为单位,这样就非常不连贯了。
这个时候,PPU中的记录滚动详细位置的寄存器就派上用场了。它们分别表示视窗相对于Coarse X和Coarse Y的偏移值,分别被称为Fine X和Fine Y。
那么,视窗(即下图中的摄像头方框)的精确位置是:
X = Coarse X * 8 + Fine X
Y = Coarse Y * 8 + Fine Y

Nametable环绕

如果视窗跨越了两个Nametable,那么在计算tile时,需要考虑跨越Nametable的情况。
例如,如果视窗横跨了Nametable ($2000->$2400),那么计算环绕的伪代码如下:

if ((v & 0x001F) == 31) // if coarse X == 31
  v &= ~0x001F          // coarse X = 0
  v ^= 0x0400           // switch horizontal nametable
else
  v += 1                // increment coarse X

如果视窗纵向跨越了Nametable($2000->$2800),那么计算环绕的伪代码如下:

if ((v & 0x7000) != 0x7000)        // if fine Y < 7
  v += 0x1000                      // increment fine Y
else
  v &= ~0x7000                     // fine Y = 0
  int y = (v & 0x03E0) >> 5        // let y = coarse Y
  if (y == 29)
    y = 0                          // coarse Y = 0
    v ^= 0x0800                    // switch vertical nametable
  else if (y == 31)
    y = 0                          // coarse Y = 0, nametable not switched
  else
    y += 1                         // increment coarse Y
  v = (v & ~0x03E0) | (y << 5)     // put coarse Y back into v

PPU内存映射汇总


Address rangeSizeDescription
$0000-$0FFF$1,000Pattern table0
$1000-$1FFF$1,000Pattern table 1
$2000-$23FF$400Nametable0
$2400-$27FF$400Nametable 1
$2800-$2BFF$400Nametable 2
$2C00-$2FFF$400Nametable 3
$3000-$3EFF$0F00Mirrors of $2000-$2EFF
$3F00-$3F1F$20Palette RAM indexes
$3F20-$3FFF$0Mirrors of $3F00-$3F1F


OAM:

Address Low NibbleDescription
$00, $04, $08, $0CSprite Y coordinate
$01, $05, $09, $0DSprite tile #
$02, $06, $0A, $0ESprite attribute
$03, $07, $0B, $0FSprite X coordinate

画面渲染实例

austinmorlan.com/posts/ 一文中大致描述了NES游戏中某一帧是如何合成的,写得通俗易懂,建议阅读。
总结来说:

  1. 卡带中的CHR RAM中保存了游戏用到的图元信息,以及每个像素的调色板编号。
  2. PPU中的Nametable记录了整个游戏的画面,其中每一个字节对应着CHR RAM中的一个图元。
  3. PPU中的Attributes table记录了每一个图元应当使用哪一个调色板。
  4. 寄存器中记录着视窗(摄像头)的位置。

暂时无法在飞书文档外展示此内容

相关文档

nesdev.org/wiki/PPU

发布于 2023-01-18 14:51・IP 属地河北