了解 background 如何绘图之后,sprite 也就简单一些了
1. OAM
细心的人可能会注意到,结合到 PPU 内存映射时会发现,PPU 总线上没有和 sprite 直接相关的信息
- pattern table:存储图像数据
- name table:存储 background 在 pattern table 的索引
- palette:调色板
以上数据没有任何一个指明了 sprite 的信息,那么 sprite 信息是放在哪里的?
这里就要提到 OAM 了,OAM 全称 Object Attribute Memory,位于 PPU 芯片中,一共 256 bytes,一个 sprite 用 4 bytes,所以总共能表示 64 个 sprite。但是 OAM 并不存在于 PPU 或 CPU 总线上,需要 CPU 通过 PPU 寄存器或者 DMA 方式才能写入
- 寄存器写入
通过 OAMADDR(0x2003) 和 OAMDATA(0x2004) 写入 OAM 数据,写入前首先通过 OAMADDR 写入起始地址,之后通过 OAMDATA 写入数据,数据可以连续写入,每写入一次地址自动 +1 - DMA 写入
通过 OAMDMA(0x4014) 写入 CPU PAGE 地址,之后 DMA 会自动将 CPU 整个 PAGE 的数据拷贝到 OAM 中。CPU PAGE 为 256 bytes,比如往 OAMDMA 写入 2,则会将 CPU 总线上的 0x200 ~ 0x2FF 的数据拷贝到 OAM。另外,DMA 会占用 512 个 CPU 时钟(奇数 CPU 周期还会再加一个时钟,前期可以先不考虑)
DMA 写入速度快于寄存器写入,所以追求效率的时候会采用此方式
2. Sprite 数据
一个 Sprite 在 OAM 中需要 4 bytes:
- Byte 0:
Sprite 的 Y 坐标 - Byte 1:
该字节类似于 name table,sprite 有 2 种模式:76543210 |||||||| |||||||+- Bank ($0000 or $1000) of tiles +++++++-- Tile number of top of sprite (0 to 254; bottom half gets the next tile)- 8 x 8
整个 byte 类似于 name table,由 PPUCTRL 的 bit 3 选取 bank 之后,加上自身数据 x 16 得到偏移量 - 8 x 16
该模式下 PPUCTRL 的 bit 3 不再起作用,bank 由 bit 0 决定,并且偏移量不再是 x 16,而是 x 32,具体参考 http://wiki.nesdev.com/w/index.php/PPU_OAM,讲得非常清楚
- 8 x 8
- Byte 2:
bit 0-1 决定高 2 bit 的 palette,类似于 attribute table 的功能76543210 |||||||| ||||||++- Palette (4 to 7) of sprite |||+++--- Unimplemented ||+------ Priority (0: in front of background; 1: behind background) |+------- Flip sprite horizontally +-------- Flip sprite vertically
bit 5 决定优先级,如果 sprite 像素和 background 像素都不是透明像素的情况下(即 palette index % 4 != 0),则决定了到底显示 sprite 还是 background
bit 6-7 决定是否翻转像素,比如人物往右走设置为不翻转,往左走则设置为垂直翻转 - Byte 3:
Sprite 的 X 坐标
3. Sprite 0 hits
Sprite 有一个非常特殊的地方,叫 精灵 0 命中,如果不实现这个功能的话,很多游戏都没法正常运行,比如马里奥 1,没实现的情况下,会一直卡在主界面
该功能的作用是,如果 PPU 在渲染的时候,如果 background 的不透明像素与 sprite 0 的不透明像素重叠的时候,会产生 sprite 0 hits,并且在当前帧只会产生一次
同时,产生 hit 也是有条件的,具体参考:http://wiki.nesdev.com/w/index.php/PPU_OAM#Sprite_zero_hits
那么它的作用是什么?主要用来分割屏幕。之前在 background 时介绍过,CPU 通过 PPUSCROLL 来控制背景移动,但是如果希望屏幕上半部分静止,下半部分移动呢?比如马里奥 1:

可以看到顶部的状态栏始终是静止的,如果 CPU 不知道 PPU 绘制到哪里的情况下,这个功能没有办法实现。通过 sprite 0,放置一个 sprite 到需要的地方,产生 hit 之后,CPU 再修改 PPUSCROLL,就能达到屏幕分割的效果了
4. 时序
这个图前面 2 章看过好几遍了

相对于 background,sprite 时序简单一些
sprite 只在 pre-render line 和 visible line 求值,在 visible line 与 background 一起渲染
PPU 内部有一块 Secondary OAM 内存,大小为 4 * 8 = 32 bytes,用来存储当前扫描线上的 8 个 sprite,这也从侧面说明:PPU 最多支持 8 个 sprite 在一条 scanline 上
- Scanline 的 1 - 64 周期,会清空 Secondary OAM,将 Secondary OAM 的值全写为 0xFF
- Scanline 的 65 - 256 周期,对下一条 scanline 的 Secondary OAM 求值,遍历 OAM 内存,将符合对应 scanline 的 OAM 写入 Secondary OAM
- Scanline 的 257 - 320 周期,通过 Secondary OAM,计算对应 scanline 的 OAM 像素值,用于之后的渲染
详细操作参考:http://wiki.nesdev.com/w/index.php/PPU_sprite_evaluation
5. 总结
至此 PPU 的原理已经全过了一遍了,sprite 内存数据和显示这里也就不展示了,和 09 章对 background 数据举例的流程相同。另外代码也就不举例了,PPU 相比 CPU 确实复杂太多,没法切分为小段代码。最好结合 fceux 强大的调试功能,结合源码查看