Loading

从零开始的nes模拟器-[3] PPU简介

PPU简介


画面

PPU简单来说就是负责实时把游戏画面渲染出来的。我们在玩游戏时看到的画面

的画面是连续的。这些动画是 一幅幅静态画面 的快速切换所制造的结果。这

一个静态画面通常被称为一个 帧( Frame ) 。我们常用 FPS ( Frame per second )

来描述帧的切换速度,一个动画的 FPS 越高,给人的感觉越流畅 。

Nes 的屏幕的 FPS 是 60Hz , 也就是说 Nes 的屏幕 一秒钟要切换 60 次画面。

对应的 PPU 也要一秒钟渲染 60 次 。

Nes 的一个帧 由 背景(background)和 精灵(sprite) 两部分组成

背景:如同字面的意义,游戏中的蓝天,草地,房子都可以做为背景,背景就是一张图像

精灵:精灵一般是游戏角色,怪物等运动的图像,比如马里奥中的主角,炮弹,蘑菇头

Nes 的 PPU 有一套独特的渲染画面的编码方式 , 后面的几个博文会相继提及到这些渲染细节。

这篇博文的目的是对 PPU的 功能进行概述, 由于PPU有自己的总线,所以我会以 PPU 的内存布局为线索,来简单讲述 PPU 各部分的功能 , 以及他们是怎么协作的 。

image-20220122104010529

Pattern Table

PatternTable ( 又被称为 CHROM) 的地址范围是 **0x0000 - 0x1FFF ** 。

这里存放了 8KB 的图像数据 ( 4 KB 用于背景4 KB 用于 精灵 ),该区域位于卡带上,由 Mapper 管理着 ( 实际上并不是说卡带里只有 8 KB 的 CHROM , 只是说同一个时刻, Nes 的 PPU 只能看见这 8KB 的地址范围里面的 CHROM , 卡带的Mapper 则是专门负责 让 PPU在合适的时候 看到 合适的 CHROM)。

CHOROM 的作用是在PPU 渲染图像的时候作为参考。( 有的游戏里面这块区域是 RAM,由 CPU 写入图像数据 , 这是很特殊的情况,要结合 Mapper 实现)。

这是一个伪Pattern Table可视化的图片 ( 这个图并不是纯粹的CHROM的可视化形式,这里实际上还包含了颜色的信息,一时半会很难讲清楚,建议看完了这个博客再回头理解这句话)

image-20220122145751924

这个大图由左右两个小图组成,这两个小图 其中一个是背景的CHROM 和 另一个精灵的 CHROM , 每个小图占用4KB的信息。

仔细观察还能知道,每个小图都是 由 16 * 16 个小正方形组成的。每个小正方形占用 16B (16 * 16 * 16 B = 4 KB)。

这些小方块是拼凑图像的最小单位,我们看到的所有游戏图像都是由这些小方块组合出来。

⏰ 注意: 下面我会沿用 小正方形 这个说法

VRAM

VRAM的地址范围是 0x2000 - 0x2FFF
这里一共 4KB 数据,其中 2KB 为主机 VRAM ( PPU生成每一帧的时候,都一定会更新这 2KB 的内容),另外 2KB 根据游戏配置为 前 2KB 的 Mirror 或者 卡带上的 VRAM

下面这个图是根据VRAM渲染出来的 , 由于 VRAM 里面的数据单纯只是负责辅助渲染背景的 (所以这个图只有 背景 , 没有精灵 )

image-20220122153135387

把这个图分成四个小图来看待,VRAM一共 4KB , 所以可以知道一个小图需要1KB的VRAM的信息

可以发现 0x2800 ~ 0x2400 也只是 0x2000 ~ 0x2C00 的镜像。

对每个小图的1KB VRAM 中都包含了 Name TableAttribute Table

NameTable 占 960B , 存储的是一系列 小正方形在PattenTable的索引

Attribute 占 64B , 存储的的是一系列 小正方形的颜色信息

Palettes

Palettes 所在的地址范围是 0x3F00 ~ 0x3F20 ( 颜色的显示是由硬件实现的 ,这个地址范围里面的东西只是颜色的映射,一个地址映射到一个颜色)

下面是NES支持的所有颜色

image-20220122144453134

Nes共支持53种颜色,Image Palettes 和 Sprite Palettes 各自只占16个地址,也就是说某一个时刻,渲染的画面/精灵只有16种颜色。(实际上还不够16个色,因为有些位置的颜色是共用的)

palette 以 4 个 bytes 为单位,0 号 byte 是共用的,比如 0x3F00 为 0x2C,则 0x3F04, 0x3F08, 0x3F0C 都是 0x2C,所以只要遇到 palette 地址 % 4 为 0 的时候,直接取 0x3F00 或者 0x3F10 的值就行了。同时 0 号 byte 也表示透明色,这个在后面介绍 sprite 优先级的时候会遇到。

image-20220130105312046

这个图只是为了说明 palette 地址 % 4 为 0 的时候颜色共用。

API PPU (部分)

按理说PPU 和 屏幕是独立的, 但是为了方便模拟渲染,这里直接把屏幕也集成到 ppu 里面了。

下面是一些上面讲到的内容的接口描述,后续博客会继续补充。

class PPU_2C02
{
  public:
    ppu_2C02();
    ppu_2C02();

  private:
    //一些连接到ppu的设备, 想象成内存就好
	// 下面模拟vram的内存布局
	// nes本身有能力存储2个nametable
	uint8_t tblName[2][1024];
	// 调色板, 包含了 Image palette 和 Sprite palette
	uint8_t tblPalette[32];
	/*Pattern Tables 
	这里存放了 8KB 的图像数据,该区域位于卡带上,由 Mapper 管理着。
	它的作用是用来 PPU 渲染图像的时候作为参考。
	有的游戏里面这块区域是 RAM,由 CPU 写入图像数据
	*/
	uint8_t tblPattern[2][4096]; // 这不是必须的,属于一个小feature,未来可用于扩展

    
     
  public: 
    // 与PPU内部总线 (各种表) 进行通信函数
    // 其中包含一些镜像地址需要转换
    uint8_t ppuRead(uint16_t addr, bool rdonly = false);
    void ppuWrite(uint16_t addr, uint8_t data);
    
    
  private:
    //  是调色板
	olc::Pixel  palScreen[0x40];
	// 代表显示器
	olc::Sprite sprScreen = olc::Sprite(256, 240);
    
    
    
	// 代表 name table (调试用的内部属性)
	olc::Sprite sprNameTable[2] = { olc::Sprite(256, 240), olc::Sprite(256, 240) };
	//  代表 pattern table (调试用的内部属性)
	//  前后 4KB 共8KB
	//  这里设置为 128 是因为 16 * 8bit = 128 (16 表示有16列tile , 8 表示每个tile 8列(8bit))
	olc::Sprite sprPatternTable[2] = { olc::Sprite(128, 128), olc::Sprite(128, 128) };


  public:
    // 用于打印各种信息的函数( 配合上面的 sprNameTable 和 sprPatternTable 调试显示 )
    olc::Sprite &GetScreen();                                 // 屏幕 (必要的)
    olc::Pixel &GetColourFromPaletteRam(uint8_t palette,  uint8_t pixel); // 获取调色后的pixel (必要的)
    olc::Sprite &GetNameTable(uint8_t i);                     // 获取名称表 (非必要的, 属于feature)
    olc::Sprite &GetPatternTable(uint8_t i, uint8_t palette); // 获取模式表 (非必要的, 属于feature)
    

    bool frame_complete = false; // 用来判断帧的绘制是否完成
    
    
}

附录:PPU读写内存布局接口

这里的代码涉及了图像渲染的知识,可以理解了渲染的过程再回来看。

// 这里区分 rdonly ( readonly 的缩写 )  是为了方便调试
// 实际的读取 ppu 数据的动作是有可能改变 ppu 内部属性的
// 但是我出于调试目的,单纯只想读取数据,所以加了一个 rdonly 标志位
uint8_t PPU_2C02::ppuRead(uint16_t addr, bool rdonly)
{
    rdonly = rdonly;
    uint8_t data = 0x00;
    addr &= 0x3FFF;
    
    // 调用卡带的接口
    if (cart->ppuRead(addr, data)) 
    {
    }
    else if (addr >= 0x0000 && addr <= 0x1FFF)
    {
        // 如果盒带无法映射地址,请使用
        // 这里有一个实际位置????
        data = tblPattern[(addr & 0x1000) >> 12][addr & 0x0FFF];
    }
    else if (addr >= 0x2000 && addr <= 0x3EFF)
    {
        addr &= 0x0FFF;

        if (cart->Mirror() == MIRROR::VERTICAL)
        {
            // Vertical
            if (addr >= 0x0000 && addr <= 0x03FF)
                data = tblName[0][addr & 0x03FF];
            if (addr >= 0x0400 && addr <= 0x07FF)
                data = tblName[1][addr & 0x03FF];
            if (addr >= 0x0800 && addr <= 0x0BFF)
                data = tblName[0][addr & 0x03FF];
            if (addr >= 0x0C00 && addr <= 0x0FFF)
                data = tblName[1][addr & 0x03FF];
        }
        else if (cart->Mirror() == MIRROR::HORIZONTAL)
        {
            // Horizontal
            if (addr >= 0x0000 && addr <= 0x03FF)
                data = tblName[0][addr & 0x03FF];
            if (addr >= 0x0400 && addr <= 0x07FF)
                data = tblName[0][addr & 0x03FF];
            if (addr >= 0x0800 && addr <= 0x0BFF)
                data = tblName[1][addr & 0x03FF];
            if (addr >= 0x0C00 && addr <= 0x0FFF)
                data = tblName[1][addr & 0x03FF];
        }
    }
    else if (addr >= 0x3F00 && addr <= 0x3FFF)
    {
        addr &= 0x001F;
        if (addr == 0x0010)
            addr = 0x0000;
        if (addr == 0x0014)
            addr = 0x0004;
        if (addr == 0x0018)
            addr = 0x0008;
        if (addr == 0x001C)
            addr = 0x000C;
        data = tblPalette[addr] & (mask.grayscale ? 0x30 : 0x3F);
    }

    return data;
}

void PPU_2C02::ppuWrite(uint16_t addr, uint8_t data)
{
    addr &= 0x3FFF;
    // 调用卡带的接口
    if (cart->ppuWrite(addr, data))
    {
    }
    else if (addr >= 0x0000 && addr <= 0x1FFF)
    {
        tblPattern[(addr & 0x1000) >> 12][addr & 0x0FFF] = data;
    }
    else if (addr >= 0x2000 && addr <= 0x3EFF)
    {
        addr &= 0x0FFF;
        if (cart->Mirror() == MIRROR::VERTICAL)
        {
            // Vertical
            if (addr >= 0x0000 && addr <= 0x03FF)
                tblName[0][addr & 0x03FF] = data;
            if (addr >= 0x0400 && addr <= 0x07FF)
                tblName[1][addr & 0x03FF] = data;
            if (addr >= 0x0800 && addr <= 0x0BFF)
                tblName[0][addr & 0x03FF] = data;
            if (addr >= 0x0C00 && addr <= 0x0FFF)
                tblName[1][addr & 0x03FF] = data;
        }
        else if (cart->Mirror() == MIRROR::HORIZONTAL)
        {
            // Horizontal
            if (addr >= 0x0000 && addr <= 0x03FF)
                tblName[0][addr & 0x03FF] = data;
            if (addr >= 0x0400 && addr <= 0x07FF)
                tblName[0][addr & 0x03FF] = data;
            if (addr >= 0x0800 && addr <= 0x0BFF)
                tblName[1][addr & 0x03FF] = data;
            if (addr >= 0x0C00 && addr <= 0x0FFF)
                tblName[1][addr & 0x03FF] = data;
        }
    }
    else if (addr >= 0x3F00 && addr <= 0x3FFF)
    {
        addr &= 0x001F;
        if (addr == 0x0010)
            addr = 0x0000;
        if (addr == 0x0014)
            addr = 0x0004;
        if (addr == 0x0018)
            addr = 0x0008;
        if (addr == 0x001C)
            addr = 0x000C;
        tblPalette[addr] = data;
    }
}
posted @ 2022-01-30 12:56  CHZarles  阅读(896)  评论(0编辑  收藏  举报