Skip to content

NES 图像技术导引

Mar 1, 2018 | 19 min read

介绍

红白机是一种价格亲民,功能强大的游戏设备,自 1983 年发行以来就造成了不小的轰动。通过使用定制设计的 PPU(Picture Processing Unit,图像处理单元)生成图像,红白机可以生成在当时给人们留下深刻印象的画面 —— 这些画面 就是在现在也不过时。但是,红白机最优秀的地方不只是图像的质量,还有就是它生成图像时对于内存的精简使用,它尽可能地使用较少的内存来生成画面。在如此节省内存的同时,任天堂却还为游戏的开发人员提供了大量强大易用的功能,这些功能使得红白机在同时代的古董游戏机中脱颖而出,成为一代经典。了解红白机使用的图像生成技术可以让我们了解并欣赏那个时代的程序员让人敬佩的技术实力,以及与现代游戏制作者打造精美图像时仅仅需要一些简单的操作即可的事实进行对比。

红白机的背景图像是由四个独立的部分组成的,这四个部分组合在一起就形成了你看到的游戏画面。每一个部分都仅仅负责处理背景图像的一个部分:颜色,位置,艺术图形,等等等等。尽管这种构造方式在现在看起来很繁琐,但是在当时看来,这种可以提高内存使用效率,且使用很少的代码就能生成简单的特效的方法是最佳选择。这四个部分可以说是红白机生成图像技术的核心内容。

本文假定读者有一部分计算机专业的常识,包括但不限于:

  1. 1 Byte = 8 Bits

  2. 8 Bits 可以表示 0 ~ 255 共 256 个值

  3. 十六进制是什么,如何表示

当然,我希望如果读者没有任何技术背景也可以在本文中了解到一些有趣的知识。

笔者水平有限,不能保证文章中所述内容全部正确。

概览

上图是 FC 游戏《恶魔城》的开场动画。图片大小为 256x240,仅仅使用了 10 种颜色。为了尽量节省内存地表示这个图像,我们可得好好利用这个图像颜色单一的特性。图样图森破的程序员们可能会可能会把颜色索引起来,用每 4 个比特去表示一个像素的颜色,如果是这样的话,我们就需要 256 x 240/2 = 30720(byte) 的空间来表示这个图片。这在现在当然是可以忽略的大小,但是注意这是在 80 年代,那年代的游戏可以使用的空间,无论是 RAM 还是 ROM 都十分有限。举个例子吧,大家熟知的超级马里奥兄弟(Super Mario Bros.)的大小仅仅 40 KB,30 KB 的空间占用在当时是不可接受的。因此,红白机使用了一种更加节省空间的图像表示法。

红白机的图像技术的核心是所谓的 tile 和 block。所谓 tile 就是一块 8x8 像素大小的区域,一个 block 就是 16x16 大小的区域。多个以上的结构对齐,就产生了我们看到的图像。下图展示了上述结构(浅绿色为 block ,深绿色为 tile):

轴上的标尺指示出每个块的十六进制值,可以以此找到位置。 例如状态栏中的心脏的位置是 15+15 + 60 = $75,就是十进制的117。 每屏有16×15个 block 和32×30个 tile。现在,我们来详细了解下红白机中的图像表示方法。

CHR

CHR 以 tile 为单位保存,用来表示图形的形状(不包括颜色和位置)。红白机的内存中仅仅包括 256 块 CHR,每块占用 16 Bytes。如下是恶魔城中的心脏,以及其 CHR 表示法:


注释 —— CHR 编码方式

每个像素的两个比特不是一起存放的。而是先把每个像素的低位保存一边,之后保存高位的。因此,上面那个心是这样保存的:

每一行是一个字节,因此每个 CHR 占据 16 字节。


下面是《恶魔城》用到的全部 CHR:

如我们之前所说,填满一个图像需要 960 个 tile,但是 CHR 最多只能有 256 个。也就是说会出现好多重复的区块。恶魔城这个游戏使用了很多空白的和全为蓝色的 tile。红白机使用命名表(Nametable)分配 tile。

命名表(NAMETABLE)

命名表将 CHR 和 tile 联系起来。每一个位置需要 1 字节表示,因此全部命名表需要 960 字节的存储空间。 命名表的存储顺序是从上到下,从左到右,并且与通过和标尺中的值相加找需要赋值的位置。

因此,左上角的 tile 是 0,它右边是0 ,它右边是 1,下面是 $20 .

命名表里面的数值和 CHR 里面的元素的编号有关。如下是一种可能的情况:

在这种情况下,心(位置 75)的值为75)的值为 13。

搞定了位置,我们还需要知道颜色,这就得选取调色板(palette)。

调色板(PALETTE)

红白机不使用我们现在熟知的 RGB 调色板,而是使用自带的硬编码的调色板,其实际生成的颜色在各个电视上并不一致。现代 NES 模拟器的作者普遍都是使用 RGB 调色板对其进行模拟。例如,笔者最近在尝试的 kuso-NES 中有如下代码:

package nes

import "image/color"

var Palette [64]color.RGBA

func init() {
	colors := [64]uint32{
		// From http://nesdev.com/pal.txt
		0x757575, 0x271B8F, 0x0000AB, 0x47009F, 0x8F0077, 0xAB0013, 0xA70000, 0x7F0B00,
		0x432F00, 0x004700, 0x005100, 0x003F17, 0x1B3F5F, 0x000000, 0x000000, 0x000000,
		0xBCBCBC, 0x0073EF, 0x233BEF, 0x8300F3, 0xBF00BF, 0xE7005B, 0xDB2B00, 0xCB4F0F,
		0x8B7300, 0x009700, 0x00AB00, 0x00933B, 0x00838B, 0x000000, 0x000000, 0x000000,
		0xFFFFFF, 0x3FBFFF, 0x5F97FF, 0xA78BFD, 0xF77BFF, 0xFF77B7, 0xFF7763, 0xFF9B3B,
		0xF3BF3F, 0x83D313, 0x4FDF4B, 0x58F898, 0x00EBDB, 0x000000, 0x000000, 0x000000,
		0xFFFFFF, 0xABE7FF, 0xC7D7FF, 0xD7CBFF, 0xFFC7FF, 0xFFC7DB, 0xFFBFB3, 0xFFDBAB,
		0xFFE7A3, 0xE3FFA3, 0xABF3BF, 0xB3FFCF, 0x9FFFF3, 0x000000, 0x000000, 0x000000,
	}

	for i, v := range colors {
		R := byte(v >> 16)
		G := byte(v >> 8)
		B := byte(v)

		Palette[i] = color.RGBA{R, G, B, 0xFF}
	}
}

游戏需要从系统自带的调色板中选出需要的颜色。游戏可以选取的调色板包含四种颜色,即三个自定义颜色,以及一个共享的背景颜色。每一张背景图片最多可以选取四个调色板,占据 16 字节的空间。如下是《恶魔城》的调色板:

当然,你不能随意使用这些调色板。每一个 block 只能使用一个调色板。因此,红白机游戏都有很强的方块感。在那个年代,有经验的程序员会通过在每块的边缘使用共享的背景颜色的方法来尽量避免那种方块感。

确定哪一个 block 使用哪一个调色板的东西叫做属性表(attributes),这是我们要讨论的最后一个元件。

属性表(ATTRIBUTES)

每一个 block 在属性表里占据 2 比特的位置,以此来表示其使用的调色板。如下是根据属性表画出来的《恶魔城》开场画面的调色板使用状况:

如你所见,调色板被分成几大部分,但这个事实通过在不同区域之间共享颜色而被程序员巧妙地隐藏起来。 大门中部的红色与周围的墙壁融为一体,而黑色背景则模糊了城堡与大门之间的界限。


注释 —— 属性表编码方式:

属性表的编码方式很奇怪,和命名表那种横平竖直的表示方法不同,属性表是每四个块组合在一起保存,按照龙摆尾的方式。例如:

308308 和 30a 348348 34a 一起保存。调色板的编号分别为 01 10 11 11,按照小端序保存,结果就是 11111001,即为 $F9。


按照任天堂给出的方案,我们存储一张图片需要的空间仅仅为 4096+960+16+64 = 5136 B,比我们之前得到的 30720 B 小了很多。完美!

在红白机游戏运行时,在内存中实际上有两个命名表。这两个表有各自的属性表,可以指定其颜色。但是他们共享一套 CHR。卡带的硬件决定两个背景的表示法,水平堆叠或者竖直堆叠。如下就是两种表示法的示例,《淘金者》和《泡泡龙》:

场景滚动

为了无缝显示背景,PPU 支持 X Y 两个方向上的图像无缝衔接。这个操作是由映射到 $2005 的寄存器控制的,你往里写入仅仅 2 个字节的数据就能随意移动屏幕。当年红白机发布时,它和别的游戏主机的优势之一就是这个,别的主机经常需要重写全部视频内存才能卷屏。这个易于使用的特性导致了这个平台上的动作游戏和射击游戏的飞速发展,奠定了红白机成功的基础。

对于淘金者这样只需要两个屏幕的游戏来说,我们需要做的仅仅就是保存填满那两张命名表,然后只需要按需移动屏幕即可。但是大多数的卷屏操作是在包含需要连续移动的游戏中做出的。为了实现这种效果,游戏需要在玩家玩到之前加载好需要的背景。这样的话,仅仅是两个命名表的轮转,就造出了关卡无限延伸的错觉。如下图所示(洛克人,笔者从 VCD 时代就最喜欢的游戏之一):

SPRITES

除了连续滚动的背景之外,红白机游戏界面的另一大组成部分就是 Sprite。和只能和网格对齐的可怜背景相比,Sprite 十分自由,能显示在任何地址。因此,这东西经常被用来表示玩家的角色,BOSS 等需要持续复杂移动的物体。例如上面那个图片中的洛克人,分数,还有血条都是使用 Sprite 实现的,这样他们就能独立于背景显示。

Sprite 有自己的 CHR 页以及一套 4 个调色板,以及一个 256 B 大小的内存记录每个 Sprite 的位置和状态。这东西的表现方式有点不正常,首先是 Y 坐标,之后是tile,之后是属性以及 X 坐标。每一个 Sprite 要占据 4 字节的存储空间,因此红白机游戏上一屏无法显示超过 64 个 sprite。

记录的 X Y 坐标是其左上角的像素的坐标。因此红白机游戏很少有从右至左通关的游戏。这里的 tile 和命名表中的类似,唯一的不同之处是这里使用他们自己的 CHR 数据。属性字节的每个比特都有自己的含义:两个用来表示选取的调色板,两个指示要水平还是竖直翻转 Sprite,还有一个字节用于指示 Sprite 和背景的相对深度等奇奇怪怪的用途。

机能限制

在现代游戏中,一个 Sprite 可以做到任意大小。但在红白机时代,因为 CHR 的限制,一个 Sprite 只能是 8x8。游戏多是通过拼合多个 Sprite 组成一个角色。例如,笔者最喜欢的洛克人就是由 10 个 Sprite 组成的,这样还有一个好啊,就是可以使用更多颜色:

看起来这样很好,但是由于我们之前说的,一屏最多只能显示 64 个 Sprite。如果屏幕上的 Sprite 不巧多于 64 个的话,因为内存会一直迭代保证最新的 Sprite 存在在内存里,图像就会发生卡顿甚至闪烁。如下图:

图像生成技术

和同时代的机器一样,红白机的输出需要由 CRT 电视(大屁股电视)接收,那种电视使用电子枪绘图一次画出画面的一行像素,从左到右从上向下。当电子枪射向最后一行时,就进入所谓的 vblank 阶段,在这个阶段电子枪移动回左上角准备生成下一帧图。在红白机中,图像生成操作由 PPU 自动处理,同时 CPU 执行需要的运算,在 vblank 阶段,CPU 把数据传进 PPU 内部。因为别的时候 PPU 的内存都在被用来进行渲染。大多数时候,PPU不内存的变化都在那段极小的时间窗口实现的。

但是,在渲染屏幕时,我们可以对PPU的某些状态进行更改,这些更改被称为“栅格效果(Raster effects)”。 最常见的情况是在屏幕中间创建一个保持静态的部分(如HUD),而让其余部分继续滚动。 要实现这种效果,需要精确计算何时更改滚动条,以便使得更改发生在所需的扫描线上。 在几十年的实践中,人们发现了在代码和PPU之间执行这种同步的诸多技术。


HUD 的例子: 下图即为HUD的例子之一:

HUD


屏幕分割

PPU 的内置硬件把位于内存位置 0 的 Sprite 特殊对待。当这一 Sprite 生成时,一旦其和背景的可见部分重叠在一起,一个被称为 Sprite0 flag 的比特位就被置 1。代码需要进行屏幕分割时,可以注意这个 Sprite0,并以 Sprite0 flag 为号,这这样就能知道需要进行分割的行数,实现屏幕分割啦。如下图:

在上图中,Sprite0 在 (a0,a0,26),因此以此为界分割屏幕。

有的游戏将这个标志位和被称为定时循环的技术结合在一起,以此造成多个屏幕分割。如《忍者龙剑传》中的过场动画就通过应用这种技术打造出了大片级的效果:

这里的多重屏幕分割就是应用的上面提到的两个技术,首先通过 Sprite0 的标志位确定第一次分割的位置,之后使用定时循环技术生成多个分割。

但是很多游戏并没由时间来等待这操作的结束,因此在后期大家倾向于使用一种特定的硬件(映射器,mapper)来进行此类操作,大幅节省时间。

映射器

映射器可以实现很多骚操作。最常见的就是 Bank switching。基于此操作,可以使得游戏的关卡和音乐的丰富度大幅提升。还可以将 HUD 和别的地方分开渲染,在过场动画时能把文本和画面保存在不同的 CHR 库中。甚至还能实现有限状况下的背景反方向滚动。

Reference

笔者编写此文所需的知识大多来自NesDev及其 WIKI