从零开始的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 各部分的功能 , 以及他们是怎么协作的 。
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的可视化形式,这里实际上还包含了颜色的信息,一时半会很难讲清楚,建议看完了这个博客再回头理解这句话)
这个大图由左右两个小图组成,这两个小图 其中一个是背景的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 里面的数据单纯只是负责辅助渲染背景的 (所以这个图只有 背景 , 没有精灵 )
把这个图分成四个小图来看待,VRAM一共 4KB , 所以可以知道一个小图需要1KB的VRAM的信息。
可以发现
0x2800 ~ 0x2400
也只是0x2000 ~ 0x2C00
的镜像。对每个小图的1KB VRAM 中都包含了 Name Table 和 Attribute Table
NameTable 占 960B , 存储的是一系列 小正方形在PattenTable的索引
Attribute 占 64B , 存储的的是一系列 小正方形的颜色信息
Palettes
Palettes 所在的地址范围是 0x3F00 ~ 0x3F20
( 颜色的显示是由硬件实现的 ,这个地址范围里面的东西只是颜色的映射,一个地址映射到一个颜色)
下面是NES支持的所有颜色
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 优先级的时候会遇到。
这个图只是为了说明 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;
}
}