Loading

从零开始的nes模拟器-[4] PPU渲染背景

PPU 背景渲染

渲染原理

我们之前了解过,PPU VRAM 一共 4KB,每 1KB 就控制着一张图像,也就是说,另外 3KB 图像始终是不会输出到屏幕上的。同时这 4 张图像以田字格的形式组合,再结合 PPU 的滚动功能,就能实现背景画面的上下左右移动

下面针对每一帧图像(每一块 VRAM),看看 PPU 是如何渲染的:

NES 以 8 x 8 像素为单位,将图片分成了 32 x 30 个小块,每一个小块称为一个瓦片(tile),同时,每 16 个 tile 组成一个大块,如下图:

image-20220122154429298

每一个 tile 在 vram 中用一个 byte 表示,该字节表示了当前 tile 在 pattern table 中的偏移量,也就表示像素的数据。

pattern table 以 16 bytes 为单位,每 16 bytes 中,有分前 8 bytes 和后 8 bytes。前后每 8 bytes 表示 tile 中每一行的 像素所处 palette 的低 2 bit,前 8 bytes 为 bit 0,后 8 bytes 为 bit 1

颜色高 2bit 由 attribute table 表示,attribute table 中每一个字节能管理 16 个 tile 的颜色,如上图中红色的大块。16 个 tile 中,每 4 个 tile 整合到一起,再和另外 12 个 tile 组成田字形格局,分别为 左上,右上,左下,右下。每个占 2 bit,刚好 8 bit

这里也能看到 NES 画面表现力不足,即每 4 个 tile 组成的部分田字格中,他们只能有 4 种不同的颜色,因为高 2 bit 固定了,剩下可变了只有低 2 bit

现在总结一下:图像分成了 32 x 30 = 960 个 tile,每个 tile 在 name table 占前 960 字节。同时 tile 在 vram 中表示 pattern table 0 - 255 的偏移量,pattern table 又以 16 bytes 为一个单位,那么总共需要 256 * 16 = 4KB 大小的 pattern table。前面讲过,pattern table 一共 8KB,可分为两个 4KB 分别给 background 或者 sprite 使用,是不是刚刚好?另外 16 个 tile 组成的大的 tile 中,每个由 attribute table 的一个字节表示,一共需要 8 x 8 = 64 bytes。加上 name table 的 960,刚好 64 + 960 = 1024 字节,即 1KB VRAM

图像的滚动

之前介绍的都是静态的情况,实际上游戏过程中画面都是运动的,这就靠 PPU 滚动来完成
之前说过,PPU 一共 4 个 1KB 的 VRAM,他们组成田字布局,把屏幕想像成窗口,PPU 滚动的时候就相当于窗口在田字格上滑动,类似于这种效果:

以 NTFS 为例,图像刷新率为 60fps,PPU 会在 1s 内产生 60 次中断告知 CPU 刷新图像(这对应了之前介绍的垂直消隐), CPU 会设置 PPU相关的寄存器以更新图像位置,一次达到图像运动的目的。

最常见的窗口滚动分为垂直滚动和水平滚动。(由卡带决定)

如果窗口在某个方向上滚动超出了所有的NameTable,那么会出现一个叫 Wrapped (环绕)的现象。

image-20220201100110109

垂直滚动

这里的M是Mirror映射的意思。换句话说,改写带 * ( M ) 的NameTable 会伴随着 更改对应的带 M ( * )的NameTable 。

image-20220201091054756

水平滚动

image-20220201091402804

其它滚动方式

一般垂直滚动和水平滚动就能应付大部分游戏了,但是有的游戏居然还支持 "沿对角线滚动" 。 但是游戏通常不会沿对角线滚动,因为它会因为名字表镜像而在屏幕边缘产生伪影。

每个游戏的盒式磁带包含硬件,允许配置名称表镜像。

image-20220201121739548

有些游戏不需要改变镜像,因此它们的盒式磁带硬连线到水平或垂直镜像。 (硬件决定了)

其他游戏需要在两种模式之间动态切换,因此它们的盒式磁带可以通过软件进行配置,水平或垂直镜像。

塞尔达的传说属于这个类别。最后,有些真正的花哨的游戏在盒式筒中有额外的视频内存,这意味着它们根本不需要镜像,并且可以同时滚动水平和垂直,没有任何可见复制。

完善 Mapper 类 和 Cartridge 类

游戏画面在NameTable的滚动方式是由卡带决定的。每个游戏的墨盒包含允许名称表镜像配置的硬件。我们要在Cartridge类里 和 Mapper类里面加上相关的信息。

在Mapper增加滚动类型

Mapper 关于滚动的类型

// 镜像模式的枚举
enum MIRROR
{
    HARDWARE,
    HORIZONTAL,
    VERTICAL,
    ONESCREEN_LO, //  这种情况适用于 Mapper1 ( MMC1 ) , 后面有专门讲 Mapper 的章节会介绍这些
    ONESCREEN_HI,
};

// 获取镜像模式的方法
// Get Mirror mode if mapper is in control
 virtual MIRROR mirror();

在Cartridge增加接口


// 即时获取Mirror模式

MIRROR Cartridge::Mirror()
{

	// 有的游戏的mirror模式是动态变化的(比如 Mapper_001 ),有的游戏的mirror模式是固定的
	// 不同类型的mappper有不同的模式,但接口是一样的
	
	// 调用 mapper 的接口
	MIRROR m = pMapper->mirror();
	if (m == MIRROR::HARDWARE)
	{
		// 镜像配置已定义 , 通过焊接在硬件中 ( 写死了 )
		return hw_mirror;
	}
	else
	{
		// 镜像配置即可 , 通过映射器动态设置
		return m;
	}
}

scroll寄存器

游戏通过写入PPUSCROLL寄存器来改变滚动的位置,PPUSCROLL寄存器被映射到地址0x2005。对PPUSCROLL的第一个写入设置滚动位置的X组件,第二个写入设置Y组件。写作继续以这种方式交替进行。

表示滚动位置的寄存器( v , t , x , w )

PPU内部维护了几个寄存器v、t、x、w, 用来记录名称表的偏移信息 (但是这些寄存器并不映射到CPU的内存布局)。参考:PPU scrolling

  • v: 当前的VRAM地址。(15 bit )
  • t: 临时的VRAM地址,也可以被看作是屏幕窗口左上角的地址。 (15 bit )
  • x: 精准x滚动(3bit)。
  • w: 第一次或第二次写时触发(1bit)。

CPU使用主内存的0x2007 ( 对应 scroll register ,下面会看到)读写数据时,PPU使用的就是当前的VRAM地址,它也被用来获取名称表的数据以绘制到屏幕上。

v 寄存器 , t 寄存器的结构是一样的 ,是一个15位的寄存器,用作当前vram的访问地址和后台滚动配置。

在对scroll写数据时,实际上对 t 寄存器写东西 (详见下面对寄存器的归纳) , 渲染的时候,会把t寄存器的内容赋值给v寄存器

当将这个值作为地址处理时,第14位被忽略 (因为PPU的内存布局中的地址最大也就是 0x3FFF ),0-13位被视为显存中的地址。

根据该表,当将寄存器作为滚动配置时,其内容的不同部分具有不同的含义

image-20220201185402929

Name Table Select is a value from 0 to 3, and selects the name table currently being drawn from. (名称表的ID

Coarse X Scroll and Coarse Y Scroll give the coordinate of a tile within the selected name table. This is the tile currently being drawn. ( 刚好是5位 , 能表示 0 ~ 32 )

Fine Y Scroll contains a value from 0 to 7, and specifies the current vertical offset of the row of pixels within the current tile. Tiles are 8 pixels square. ( 简单来说就是表示 tile 内部的 y 值 )

Fine X Scroll is absent from this register. There is a separate register which just contains the horizontal offset of the current pixel, but it won’t be relevant( 相关 ) for explaining how The Legend of Zelda performs vertical scrolling. ( fine x 独立于v,t 寄存器

该寄存器的代码定义

// 滚动寄存器
union loopy_register
	{
	
		struct
		{

			uint16_t coarse_x : 5;
			uint16_t coarse_y : 5;
			uint16_t nametable_x : 1;
			uint16_t nametable_y : 1;
			uint16_t fine_y : 3;
			uint16_t unused : 1;
		};

		uint16_t reg = 0x0000;
	};

更新 v 寄存器

    // 传输临时存储的水平nametable访问信息
    // 进入“指针”。请注意,精细x滚动不是“指针”的一部分
    // 寻址机制
    auto TransferAddressX = [&]() {
        // Ony if rendering is enabled
        if (mask.render_background || mask.render_sprites)
        {
            vram_addr.nametable_x = tram_addr.nametable_x;
            vram_addr.coarse_x = tram_addr.coarse_x;
        }
    };

    // 传输临时存储的垂直名称表访问信息
    // 进入“指针”。请注意,精细的y形滚动是“指针”的一部分
    // 寻址机制
    auto TransferAddressY = [&]() {
        // Ony if rendering is enabled
        if (mask.render_background || mask.render_sprites)
        {
            vram_addr.fine_y = tram_addr.fine_y;
            vram_addr.nametable_y = tram_addr.nametable_y;
            vram_addr.coarse_y = tram_addr.coarse_y;
        }
    };

向右滚动

	// 将背景平铺“指针”水平增加一个平铺/列
    auto IncrementScrollX = [&]() {
        // Note: pixel perfect scrolling horizontally is handled by the  data
        // shifters. Here we are operating in the spatial domain of   tiles, 8x8
        // pixel blocks. 注:像素完美水平滚动由数据移位器处理。
        // 在这里,我们在瓦片的空间域中操作,即8x8像素块。
        // 如果绘制
        if (mask.render_background || mask.render_sprites)
        {
            // A single name table is 32x30 tiles. As we increment horizontally
            // we may cross into a neighbouring nametable, or wrap around to
            // a neighbouring nametable
            // 单个名称表为32x30平铺。当我们水平增加时
            // 我们可能会跨进相邻的一张姓名表,或者绕到另一张姓名表上
            if (vram_addr.coarse_x == 31)
            {
                // Leaving nametable so wrap address round
                // 离开nametable,将地址环绕
                vram_addr.coarse_x = 0;
                // Flip target nametable bit
                // 翻转目标命名表位
                vram_addr.nametable_x = ~vram_addr.nametable_x;
            }
            else
            {
                // 依然在当前名称表中,所以只需增加coarse_x
                vram_addr.coarse_x++;
            }
        }
    };

  

向下滚动

    // ==============================================================================
    auto IncrementScrollY = [&]() {


        // 垂直递增更为复杂。可见名称表
        // 是32x30瓷砖,但内存中有足够的空间容纳32x32瓷砖。
        // 底部的两排瓷砖实际上根本不是瓷砖,它们
        // 包含整个表的“属性”信息。这是
        // 描述用于不同应用程序的选项板的信息
        // 名称表的区域。
        // 此外,网元不会以8像素的块垂直滚动
        // 即瓷砖的高度,它可以通过使用
        // 寄存器的精细部分。这意味着Y的增量
        // 首先调整精细偏移,但可能需要调整整个偏移
        // 行偏移,因为fine_y是一个0到7的值,一行高8像素
        if (mask.render_background || mask.render_sprites)
        {
            // If possible, just increment the fine y offset
            if (vram_addr.fine_y < 7)
            {
                vram_addr.fine_y++;
            }
            else
            {
                // 如果我们已经超过了一排的高度,我们需要
                // 增加行数,可能会换行到相邻行中
                // 垂直名称表。不过,别忘了下面两排
                // 不包含平铺信息。使用粗略的y偏移
                // 确定我们想要的名称表的哪一行,以及
                // y偏移是特定的“扫描线”

                // find_y是title内的像素y坐标扫描线
                vram_addr.fine_y = 0;

                // coarse_y记录的是名称表内的y扫描线
                if (vram_addr.coarse_y == 29)
                {
           
                    vram_addr.coarse_y = 0;
          
                    vram_addr.nametable_y = ~vram_addr.nametable_y;
                }
                else if (vram_addr.coarse_y == 31) // 感觉删了也没影响
                {

                    // 进入名称表的属性范围边界
                    vram_addr.coarse_y = 0;
                }
                else
                {

                    vram_addr.coarse_y++;
                }
            }
        }
    };

时序

概览

在理解时序之前要先了解以前的显示器的成像原理: CRT显示器

简单来说就是,Nes 屏幕大小是 256 * 240 , 但是实际上绘制一帧需要的扫描线有 260 条,并且每条扫描线有 340 个像素点, ,而每一个PPU的时钟周期,绘制一个像素点

时序图是统筹渲染的时间表,要读懂时序,才能写出有效的代码。

下面这个是完整的时序图

img

图像解读

时序:

  • PPU 时钟为 CPU 的 3 倍,每一个 PPU 时钟扫描一个点,每一帧一共扫描 262 行,每一行扫描 341 个点,并且只有在扫描线 0 - 239 的 1 - 256 点可见
  • 除了(0, 0)以外,每行第一个点不做任何事情,(0,0)会在使能背景显示并且在奇数帧时跳过

像素读取:

图中的黄色部分每 8 个点看作一个单位( **8个circle 用来准备未来要绘制的tile , 这些准备的数据会保存在 “ 移位寄存器 ” 里 ** ):

  • 时钟 0 - 1 取 Name table 数据 (实际上是 tile 的索引)
  • 时钟 2 - 3 取 Attribute table 数据 ( 实际上是 palette 的索引 )
  • 时钟 5 - 6 读取 tile 低 8 位 (LSB)
  • 时钟 7 - 8 读取 tile 高 8 位 (HSB)

读取的数据会进入锁存器,然后每一个时钟进入移位寄存器 (后面会涉及),以提供绘图使用,数据读取方法和锁存器在 NESDEV 上已经提供了伪代码了,这里不再重复,参考:
http://wiki.nesdev.com/w/index.php/PPU_scrolling
http://wiki.nesdev.com/w/index.php/PPU_rendering

每一个可见的时钟都会从移位寄存器取出 palette index,进行绘图。图中一些不可见的时钟也存在取数据的操作(比如 321 - 340),这是因为需要预先将移位寄存器塞满

中断:

scanline 240 cycle 1 时,有一个 set VBlank flag 的操作

​ 这时候会将 PPU 状态寄存器中的 VBlank 标志置位

​ 并且如果 PPU 使能(enable)了中断 (这个要看PPU控制寄存器),会向 CPU 触发一个 NMI 中断提醒 CPU 进行绘图更新

最后会在 scanline 261 cycle 1 时清除 Blank

利用滚动寄存器渲染

滚动寄存器配套使用的内部数据有

    // 背景像素渲染信息
    // 要配合上面面的时序理解这个变量
    uint8_t bg_next_tile_id = 0x00;   //时钟 0~1 修改这个值
    uint8_t bg_next_tile_attrib = 0x00; // 时序 2~3修改这个值
    uint8_t bg_next_tile_lsb = 0x00; // 时序 4~5 修改这个值,当前渲染的像素,所在的lsb对应行的信息
    uint8_t bg_next_tile_msb = 0x00; // 时序 6~7 修改这个值, 当前渲染的像素,所在的msb对应行的信息

    // 移位寄存器,根据这些移位寄存器来渲染信息,这些移位寄存器是通过上面的
    // 预准备的数据存在低8位 (也就是说存在低8位的信息是留给下一个 "8周期" 使用的)
    // 当前渲染用到的数据存在高8位,所以每次跨越tile的时候,这些寄存器都要移位
    uint16_t bg_shifter_pattern_lo = 0x0000; 
    uint16_t bg_shifter_pattern_hi = 0x0000;
    uint16_t bg_shifter_attrib_lo = 0x0000;
    uint16_t bg_shifter_attrib_hi = 0x0000;

利用滚动寄存器渲染的原理

image-20220205101250342

uint16_t bg_shifter_pattern_lo = 0x0000; // 对应 LSB 
uint16_t bg_shifter_pattern_hi = 0x0000; // 对应 MSB
uint16_t bg_shifter_attrib_lo = 0x0000;  // 对应 PAL Lo 
uint16_t bg_shifter_attrib_hi = 0x0000;  // 对应 PAL HI

更新滚动寄存器的代码

    // 每个周期,存储模式和属性信息的移位器移位
    // 它们的内容是1位的。这是因为每一个周期,输出都在进行
    // 1像素。这意味着换档杆的状态相对而言是同步的
    // 为扫描线的8像素部分绘制像素。

    auto UpdateShifters = [&]() {
        if (mask.render_background)
        {
            // Shifting background tile pattern row
            bg_shifter_pattern_lo <<= 1;
            bg_shifter_pattern_hi <<= 1;

            // Shifting palette attributes by 1
            bg_shifter_attrib_lo <<= 1;
            bg_shifter_attrib_hi <<= 1;
        }
        if (mask.render_sprites && cycle >= 1 && cycle < 258)
        {
            for (int i = 0; i < sprite_count; i++)
            {
                if (spriteScanline[i].x > 0)
                {
                    spriteScanline[i].x--;
                }
                else
                {
                    sprite_shifter_pattern_lo[i] <<= 1;
                    sprite_shifter_pattern_hi[i] <<= 1;
                }
            }
        }
    };

载入滚动寄存器代码

    // 为“有效”背景平铺移位器加上底漆,以便下一步输出
    // 扫描线为8像素。
    auto LoadBackgroundShifters = [&]() {

        // 每次PPU更新我们计算一个像素。这些移位器沿方向移位1位
        // 向像素合成器提供所需的二进制信息。它的
        // 16位宽,因为前8位是当前正在绘制的8个像素
        // 底部的8位是接下来要绘制的8个像素。当然这意味着
        // 所需位始终是移位器的MSB。但是,“精细x”滚动
        // 这也起了一定作用,后面会看到,所以事实上我们可以选择
        // 前8位中的任意一位。

        bg_shifter_pattern_lo = (bg_shifter_pattern_lo & 0xFF00) | bg_next_tile_lsb;
        bg_shifter_pattern_hi = (bg_shifter_pattern_hi & 0xFF00) | bg_next_tile_msb;

        bg_shifter_attrib_lo = (bg_shifter_attrib_lo & 0xFF00) | ((bg_next_tile_attrib & 0b01) ? 0xFF : 0x00);
        bg_shifter_attrib_hi = (bg_shifter_attrib_hi & 0xFF00) | ((bg_next_tile_attrib & 0b10) ? 0xFF : 0x00);
    };

渲染对应代码

    // 此时已经获得了所有的前景(精灵)和背景的像素信息,此时需要做合成了
    // 其中,背景像素块的信息中颜色信息,每8个bit需要改变一次
    uint8_t bg_pixel = 0x00;   // The 2-bit pixel to be rendered
    uint8_t bg_palette = 0x00; // The 3-bit index of the palette the pixel indexes

    //如果启用了 PPU,我们只会渲染背景。注意如果
    // 背景渲染被禁用,像素和调色板结合
    // 形成 0x00。这将通过颜色表产生
    // 当前生效的背景颜色
    if (mask.render_background)
    {

        // 通过选择相关位来处理像素选择
        // 取决于精细 x scolling。
        // 这具有以下效果 :
        // 将所有背景渲染偏移一个设定的数字像素,允许平滑滚动
        uint16_t bit_mux = 0x8000 >> fine_x;


        uint8_t p0_pixel = (bg_shifter_pattern_lo & bit_mux) > 0;
        uint8_t p1_pixel = (bg_shifter_pattern_hi & bit_mux) > 0;

       
        bg_pixel = (p1_pixel << 1) | p0_pixel;

       
        uint8_t bg_pal0 = (bg_shifter_attrib_lo & bit_mux) > 0;
        uint8_t bg_pal1 = (bg_shifter_attrib_hi & bit_mux) > 0;
        bg_palette = (bg_pal1 << 1) | bg_pal0;
    }

这里已经得到了 bg_palettebg_pixel , 用两个量已经可以确定对应像素点颜色了。

划分时序区间

下面是时序图的简化形式,这样设计时序是为了让电子枪在绘制 screen 以外的区域(这些区域不会被显示)的时候,cpu能够利用这些时间间隙来和ppu通讯。

+--------+ 0 ----+
|        |       |
|        |       |
| Screen |       +-- (0-239) 256x240 on-screen results
|        |       |
|        |       |
+--------+ 240 --+
|   ??   |       +-- (240-242) Unknown
+--------+ 243 --+
|        |       |
| VBlank |       +-- (243-262) VBlank
|        |       |
+--------+ 262 --+

扫描线和时钟计数

void clock() {
 
    .......
        
	cycle++;
	if (cycle >= 341)
	{
		cycle = 0;
		scanline++;
		if (scanline >= 261)
		{
			scanline = -1;
            // 标记这个背景帧是不是完成
			frame_complete = true;
            // 奇数帧 <-> 偶数帧
            odd_frame = !odd_frame;
		}
	}
    
   .......
}

这里把第262条 scanline 设置为-1, 方便标识。

划分区间

	// 除第 1 条 ( 准确来说是 scanline == -1 )之外的所有 secanlines 对用户都是可见的。
	// 在 -1 处的预渲染扫描线,用于配置第一条可见扫描线的“移位寄存器”
       // scanline 在 [-1 , 240 ) 是可见区间
	// 内部还会划分各种时钟周期
	if (scanline >= -1 && scanline < 240)
	{
        // 这里的scanline 指的是行数 ,渲染的行数为 0 - 239
        // cycle 指的是 Pixel 列数
        // 如果是基础
		if (scanline == 0 && cycle == 0 && odd_frame)
		{
			// "Odd Frame" cycle skip
			cycle = 1;
		}
		
        // 预处理
		if (scanline == -1 && cycle == 1)
		{
			// Effectively start of new frame, so clear vertical blank flag
            ...
		}
		
		// 执行动作的时钟区间
		if ((cycle >= 2 && cycle < 258) || (cycle >= 321 && cycle < 338))
		{
			
            //在这些cycle中,我们收集和处理可见数据
            //“移位器”已经在前一个扫描线的末尾与该扫描线开始的数据预加载。
			switch ((cycle - 1) % 8)
			{
			case 0:
                  ...
				break;
			case 2:
                  ...
				break;
			case 4:
                  ...
				break;
			case 6:
                  ...
				break;
			case 7:
                  ...
				break;
			}
		}

		// End of a visible scanline, so increment downwards...
		if (cycle == 256)
		{
			....
		}

		//...and reset the x position
		if (cycle == 257)
		{
			...
		}

		// Superfluous reads of tile id at end of scanline
		if (cycle == 338 || cycle == 340)
		{
			...
		}

		if (scanline == -1 && cycle >= 280 && cycle < 305)
		{
			...
		}
	}
	
	// 下面是不可见的扫描线区间
	if (scanline == 240)
	{
		// Post Render Scanline - Do Nothing!
	}

	// 最后的要准备的扫描线区间
	if (scanline >= 241 && scanline < 261)
	{
		if (scanline == 241 && cycle == 1)
		{
			...... 
		}
	}


   // 进行渲染
	....

这个是时序实现的框架。 接下来按照时序来探讨各部分实现

1.不可视期: 禁止中断,跳出vblank
if (scanline == -1 && cycle == 1)
		{
			// Effectively start of new frame, so clear vertical blank flag
			status.vertical_blank = 0;
		}
2 . 可视期 : 常规操作
        if ((cycle >= 2 && cycle < 258) || (cycle >= 321 && cycle < 338))
        {
            UpdateShifters();

            // 在这些周期中,我们收集和处理可见数据
            // “换档杆”已在上一节结束时预加载
            // 带有此扫描线起点数据的扫描线。一旦我们
            // 离开可见区域,我们进入休眠状态,直到移位器被移除
            // 为下一条扫描线预加载。
            // 幸运的是,对于背景渲染,我们经历了一个相当复杂的过程
            // 可重复的事件序列,每2个时钟周期。

            switch ((cycle - 1) % 8)
            {
            case 0:
                // Load the current background tile pattern and attributes into the
                // "shifter" 这个是背景,所有的x坐标开始都是 8 的整数倍
                LoadBackgroundShifters();

                // Fetch the next background tile ID
                // "(vram_addr.reg & 0x0FFF)" : Mask to 12 bits that are relevant
                // "| 0x2000"                 : Offset into nametable space on PPU
                // address bus 获取下一个背景title的ID号(名称表中)
                bg_next_tile_id = ppuRead(0x2000 | (vram_addr.reg & 0x0FFF));

                // 说明:
                // 循环寄存器的底部12位提供到的索引
                // 这4个名称表,与名称表镜像配置无关。
                // 名称表_y(1)名称表_x(1)粗略_y(5)粗略_x(5)
                //
                // 考虑单个可指定的是32×32数组,并且我们有四个数组。

                //   0                1
                // 0 +----------------+----------------+
                //   |                |                |
                //   |                |                |
                //   |    (32x32)     |    (32x32)     |
                //   |                |                |
                //   |                |                |
                // 1 +----------------+----------------+
                //   |                |                |
                //   |                |                |
                //   |    (32x32)     |    (32x32)     |
                //   |                |                |
                //   |                |                |
                //   +----------------+----------------+
                //
                // This means there are 4096 potential locations in this array, which
                // just so happens to be 2^12!

                // 这意味着此阵列中有4096个潜在位置
                // 正好是2^12!
                break;
            case 2:


                // 获取下一个背景平铺属性。好的,所以这个有点
                // 更多参与:P
                // 回想一下,每个nametable都有两行不是平铺的单元格
                // 信息,而是表示
                // 指示将哪些选项板应用于屏幕上的哪个区域。
                // 重要的是(令人沮丧的是)没有1:1的对应关系
                // 在背景平铺和调色板之间。两行磁贴数据保持不变
                // 64个属性。因此,我们可以假设属性会影响
                // 屏幕上该名称表的8x8区域。给出了一个工作决议
                // 对于256x240,我们可以进一步假设每个区域为32x32像素
                // 在屏幕空间或4x4平铺中。分配了四个系统选项板
                // 要进行背景渲染,只需使用
                // 2位。因此,属性字节可以指定4个不同的选项板。
                // 因此,我们甚至可以进一步假设一个调色板是
                // 应用于4x4瓷砖区域的2x2瓷砖组合。事实本身
                // 背景瓷砖在本地“共享”调色板就是原因
                // 在一些游戏中,你会看到屏幕边缘颜色的失真。
                // 与前面一样,在选择磁贴ID时,我们可以使用
                // 循环寄存器,但我们需要使实现“更粗糙”
                // 因为我们需要的是属性字节,而不是特定的磁贴
                // 一组4x4瓷砖,或者换句话说,我们划分32x32地址
                // 乘以4,得到一个等效的8x8地址,我们将该地址偏移
                // 进入目标名称表的属性部分。
                // 将12位循环地址重新构造为
                // 属性存储器

                // "(vram_addr.coarse_x >> 2)"        : integer divide coarse x by 4,
                //                                      from 5 bits to 3 bits
                // "((vram_addr.coarse_y >> 2) << 3)" : integer divide coarse y by 4,
                //                                      from 5 bits to 3 bits,
                //                                      shift to make room for coarse
                //                                      x

                // Result so far: YX00 00yy yxxx


                // 名称表中的所有属性内存从0x03C0开始,so或with
                // 结果以选择目标名称表和属性字节偏移量。最后
                // 或使用0x2000偏移到PPU总线上的nametable地址空间。
                bg_next_tile_attrib = ppuRead(0x23C0 | (vram_addr.nametable_y << 11) | (vram_addr.nametable_x << 10) |
                    ((vram_addr.coarse_y >> 2) << 3) | (vram_addr.coarse_x >> 2));



                // 我们已经读取了指定地址的正确属性字节,
                // 但是字节本身被进一步分解为2x2平铺组
                // 在4x4属性区域中。
                // 属性字节是这样组合的:BR(76)BL(54)TR(32)TL(10)
                //
                // +----+----+                +----+----+
                // | TL | TR |                | ID | ID |
                // +----+----+ where TL =   +----+----+
                // | BL | BR |                | ID | ID |
                // +----+----+                +----+----+
                //


                // 因为我们知道可以直接从12位地址访问磁贴,所以我们
                // 可以分析粗坐标的底部位,为我们提供
                // 将正确的偏移量转换为8位字,以产生所需的2位
                // 实际感兴趣的是指定2x2组
                // 瓷砖。我们知道如果“粗y%4”<2,我们在上半部分,否则在下半部分。
                // 同样,如果“粗略x%4”小于2,则我们位于左半部分,否则位于右半部分。
                // 最终,我们希望属性词的底部两位是
                // 选择调色板。所以按要求换班。。。
                if (vram_addr.coarse_y & 0x02)
                    bg_next_tile_attrib >>= 4;
                if (vram_addr.coarse_x & 0x02)
                    bg_next_tile_attrib >>= 2;
                bg_next_tile_attrib &= 0x03;
                break;

                // Compared to the last two, the next two are the easy ones... :P

            case 4:


                // 从模式内存中获取下一个背景平铺LSB位平面
                // 已从名称表中读取磁贴ID。我们将使用此id
                // 索引到模式内存中以找到正确的精灵(假设
                // 精灵位于内存中的8x8像素边界上,它们就是这样做的
                // 即使存在8x16精灵,因为背景平铺始终为8x8)。
                //
                // 由于精灵实际上是1位深,但8像素宽,我们
                // 可以将整个sprite行表示为单个字节,因此偏移
                // 进入模式记忆很容易。总共有8KB,所以我们需要一个
                // 13位地址。

                // "(control.pattern_background << 12)"  : the pattern memory selector
                //                                         from control register,
                //                                         either 0K or 4K offset
                // "((uint16_t)bg_next_tile_id << 4)"    : the tile id multiplied by
                // 16, as
                //                                         2 lots of 8 rows of 8 bit
                //                                         pixels
                // "(vram_addr.fine_y)"                  : Offset into which row based
                // on
                //                                         vertical scroll offset
                // "+ 0"                                 : Mental clarity for plane
                // offset Note: No PPU address bus offset required as it starts at
                // 0x0000
                bg_next_tile_lsb = ppuRead((control.pattern_background << 12) + ((uint16_t)bg_next_tile_id << 4) +
                    (vram_addr.fine_y) + 0);

                break;
            case 6:
                // Fetch the next background tile MSB bit plane from the pattern
                // memory This is the same as above, but has a +8 offset to select the
                // next bit plane
                bg_next_tile_msb = ppuRead((control.pattern_background << 12) + ((uint16_t)bg_next_tile_id << 4) +
                    (vram_addr.fine_y) + 8);
                break;
            case 7:
                // Increment the background tile "pointer" to the next tile
                // horizontally in the nametable memory. Note this may cross nametable
                // boundaries which is a little complex, but essential to implement
                // scrolling
                IncrementScrollX();
                break;
            }
        }
3. 可视期:设置滚动寄存器
		// End of a visible scanline, so increment downwards...
		if (cycle == 256)
		{
			IncrementScrollY();
		}

		//...and reset the x position
		if (cycle == 257)
		{
			LoadBackgroundShifters();
			TransferAddressX();
		}

		// Superfluous reads of tile id at end of scanline
		if (cycle == 338 || cycle == 340)
		{
			bg_next_tile_id = ppuRead(0x2000 | (vram_addr.reg & 0x0FFF));
		}

		if (scanline == -1 && cycle >= 280 && cycle < 305)
		{
			// End of vertical blank period so reset the Y address ready for rendering
			TransferAddressY();
		}	// End of a visible scanline, so increment downwards...

4. 不可视期:允许中断

进入 unkonow 区域时,设置 vblank 。意味着CPU在这个时刻可以设置CPU的信息。

if (scanline >= 241 && scanline < 261)
	{
		if (scanline == 241 && cycle == 1)
		{
			// 设置垂直空白标志
			status.vertical_blank = 1;

		    // 此时控制寄存器允许cpu进行nmi中断
			if (control.enable_nmi)
				nmi = true;
		}
	}

可以这样理解,nmi中断相当于是告知cpu, ppu已经把图绘制好了,现在cpu可以为绘制下一帧做准备了。

在主时钟上,可以这样协调组织两者。 (实际的主时钟函数没有那么简单,这个代码的意义在于把关键动作框架勾勒出来)

void clock()
{
//运行频率由调用此函数的任何函数控制。因此,这里我们根据需要“划分”时钟,并在正确的时间调用外围设备clock()函数。

	//Nes系统中最快的时钟即是PPU时钟。
//所以PPU的频率和nes系统的时钟频率是一样的。
	ppu.clock();

	// CPU运行速度比PPU慢3倍

	if (nSystemClockCounter % 3 == 0)
	{
		cpu.clock();
	}

// PPU的中断通常发生在vertical blanking period
// 此时cpu需要进行下一帧的名称表的准备
// 不可能在绘制的时间进行准备,只能等到绘制结束,否则会产生很多问题
// 唯一的机会就是在垂直消隐期间进行
	if (ppu.nmi)
	{
		ppu.nmi = false;
		cpu.nmi();
	}

// 用一个全局计数器来计数主时钟。
	nSystemClockCounter++;
}

CPU和PPU的通信 - PPU的寄存器

图像滚动依赖于CPU向PPU写入滚动信息。 所以PPU要向CPU暴露写信息的接口

PPU和CPU之间通过9个寄存器来互通信息。

image-20220122174421498

这是CPU的内存布局中和通信相关的区域,CPU通过访问这里的地址来写PPU的寄存器,进而改写PPU的状态。

这些是和滚动相关的寄存器 ,下面会陆续提及

image-20220122181232981

CTRL : 负责设置PPU的参数,以切换渲染方式

MASK : 相当于一个开关,决定是渲染 background 还是

Spririt ,以及决定一些 “屏幕” 边缘的渲染动作

STATUS : 负责告知CPU/PPU,安全渲染/准备的时机。

SCROLL : 和图像滚动信息有关

ADDR : 显存指针,只读的

DATA : 读写显存

下面着重解释一下 ADDR 和 DATA

PPUADDR 寄存器,用于设置当前渲染的vram的地址。例如,当游戏想要改变nameTable的一个tile时,它首先将tile 的vram地址写入PPUADDR 寄存器,然后将tile的新值写入PPUDATA 寄存器

在非vblank 期间写入PPUADDR 寄存器 (例如:正在绘制画面时) 会导致图形伪像( graphical artefacts)。这是因为PPU在从vram中检索tile以绘制tile时,PPU也会直接操纵写入PPUADDR的PPU电路 (( 言下之意是两者会冲突 )。

随着绘图从屏幕的顶部到底部进行,并且在每个像素行中从左到右进行,PPU有效地将PPUADDR设置为包含当前正在绘制的像素的tile的地址。当绘图从一个tile移动到另一个tile时,通过增加其当前值来改变PPUADDR。

因此,对ppaddr的中间帧的写入可以改变PPU在当前帧期间从内存中获取的tile。

控制寄存器

image-20220131143137787

定义

    union PPUCTRL {
        struct
        {
            uint8_t nametable_x : 1;
            uint8_t nametable_y : 1;
            uint8_t increment_mode : 1;
            uint8_t pattern_sprite : 1;
            uint8_t pattern_background : 1;
            uint8_t sprite_size : 1;
            uint8_t slave_mode : 1; // unused
            uint8_t enable_nmi : 1;
        };

        uint8_t reg;
    } control; // 控制寄存器

cpu读操作

// 没有写权限

cpu写操作

 control.reg = data;
// 临时存储要在不同时间“转移”到“指针”中的信息
// 后面会解释变量 tram_addr 的含义
 tram_addr.nametable_x = control.nametable_x;
 tram_addr.nametable_y = control.nametable_y;

掩码寄存器

image-20220131143235418

可以看成是一系列开关

    union {
        struct
        {
            uint8_t grayscale : 1;
            uint8_t render_background_left : 1;
            uint8_t render_sprites_left : 1;
            uint8_t render_background : 1; 
            uint8_t render_sprites : 1;
            uint8_t enhance_red : 1;
            uint8_t enhance_green : 1;
            uint8_t enhance_blue : 1;
        };

        uint8_t reg;
    } mask; // 掩码寄存器

cpu读操作

// 没有写权限

cpu写操作

 mask.reg = data;

ADDR (显存指针)

image-20220131144905332

定义

union loopy_register
	{
		// Credit to Loopy for working this out :D
		struct
		{

			uint16_t coarse_x : 5;
			uint16_t coarse_y : 5;
			uint16_t nametable_x : 1;
			uint16_t nametable_y : 1;
			uint16_t fine_y : 3;
			uint16_t unused : 1;
		};

		uint16_t reg = 0x0000;
	};


	// 指向 nametable 地址的指针v
	loopy_register vram_addr; 
	// 将要保存到指针v指向的地址的临时信息,信息记录的是滚动位置
	loopy_register tram_addr; 

cpu读操作

// 没有权限

cpu写操作

// PPU Address
// ppu地址是一个16字节地址,所以分两次传输
// 第一次传输高8字节
// 第二次传输底8字节
// 两个周期
if (address_latch == 0)
{

            // CPU可通过ADDR和DATA访问PPU地址总线寄存器
            // 第一次写入该寄存器将锁存高字节
            // 在地址中,第二个是低字节。
            // tram_addr.reg=(uint16_t)((data&0x3F)<<8)|(tram_addr.reg&0x00FF);
            tram_addr.reg = (uint16_t)((data & 0x3F) << 8); // fix something
            address_latch = 1;
}
else
{
            // 写入整个地址后,内部vram地址
            // 缓冲区已更新。在渲染期间不能写入PPU
            // 因为PPU将自动维护vram地址,同时更新
            // 渲染扫描线位置。
            tram_addr.reg = (tram_addr.reg & 0xFF00) | data;
            vram_addr = tram_addr;
            address_latch = 0;
}

address_latch(地址锁存器), 是控制写低位/高位的变量。

DATA (显存数据)

image-20220131151201913

cpu读操作

// https://wiki.nesdev.com/w/index.php?title=PPU_programmer_reference#Data_.28.242007.29_.3C.3E_read.2Fwrite
// 从 NameTable ram 读取会延迟一个周期,
// 所以输出缓冲区包含来自之前的读请求
data = ppu_data_buffer; 
// CPU 读取并获取内部缓冲区的内容后
// PPU 会立即使用当前 VRAM 地址处的字节更新内部缓冲区 (为下一次读做准备)
ppu_data_buffer = ppuRead(vram_addr.reg); 

//但是,如果地址在调色板范围内,则数据没有延迟,所以立即返回
 if (vram_addr.reg >= 0x3F00)
        data = ppu_data_buffer; 
 
// 根据控制寄存器中设置的模式,
// 所有从 PPU 数据读取的数据都会自动递增命名表地址。
// 如果设置为垂直模式,则增量为 32,因此跳过一整行名称表;
// 在水平模式下它只是增加 1,移动到下一列
vram_addr.reg += (control.increment_mode ? 32 : 1);

cpu写操作

// PPU Data
// 调用PPU的接口
ppuWrite(vram_addr.reg, data);
//  根据控制寄存器中设置的模式,所有从 PPU
//  数据写入的数据都会自动递增命名表地址。
// 如果设置为垂直模式,则增量为 32,
// 因此跳过一整行名称表;
// 在水平模式下它只是增加 1,移动到下一列
vram_addr.reg += (control.increment_mode ? 32 : 1);

状态寄存器

image-20220131143308397

定义

    union {
        struct
        {
            uint8_t unused : 5;
            uint8_t sprite_overflow : 1;
            uint8_t sprite_zero_hit : 1;
            uint8_t vertical_blank : 1;
        };

        uint8_t reg;
    } status; // 状态寄存器

cpu读操作

// 读操作也会改变内部的信息
// 只有后三位存有有效信息
// https://wiki.nesdev.com/w/index.php?title=PPU_programmer_reference#Status_.28.242002.29_.3C_read
// 0xE0 = 0b11100000   0x1F = 0b00011111
// ppu_data_buffer 之前写入 PPU 寄存器的最低有效位
 data = (status.reg & 0xE0) | (ppu_data_buffer & 0x1F); // 后面这个 (ppu_data_buffer & 0x1F) 其实是不必要的

// Clear the vertical blanking flag
status.vertical_blank = 0;

// Reset Loopy's Address latch flag
address_latch = 0;

.....
    
// 返回数据
return data;

cpu写操作

// 没有权限

SCROLL 寄存器

image-20220131153249468

我的实现省略了这个寄存器,实际上直接把数据保存到t寄存器就足够了,因为整个渲染过程主要依靠内部寄存器,SCROLL寄存器根本就不会被更改。

cpu读操作t

// 没有权限

cpu写操作

// 滚动功能实现寄存器
// 通过address_latch 判断是水平滚动还是垂直滚动
// 这个scroll执行两次,先执行x 后执行 y
if (address_latch == 0)
{
            // 首次写入滚动寄存器包含像素空间中的X偏移量
            // 我们将其分为粗x值和细x值
            // 细x值保存的是像素x偏移
            // 粗x值保存名称表的偏移
			fine_x = data & 0x07;
			tram_addr.coarse_x = data >> 3;
			address_latch = 1;
}
else
{
            // 首次写入滚动寄存器包含像素空间中的Y偏移量
            // 我们将其分为粗y值和细y值
            // 细y值保存的是像素y偏移
            // 粗y值保存名称表的偏
			tram_addr.fine_y = data & 0x07;
			tram_addr.coarse_y = data >> 3;
			address_latch = 0;
}
posted @ 2022-02-06 17:22  CHZarles  阅读(450)  评论(0编辑  收藏  举报