为了在“塞尔达传说”的第一部分中垂直滚动效果,使用了NES图形“硬件”操作,这很可能不是控制台开发人员提供的。
我无权访问NES控制台的图片处理单元(PPU-图形芯片)的官方文档,因此我对“未定义行为”的陈述更有可能是猜测。 我从
NesDev Wiki获取了图形硬件的
规范 。 通过写入具有存储器映射的寄存器来控制PPU。 如果以设计人员设想的方式使用这些寄存器,那么将无法实现这种效果:
垂直滚动屏幕时,整个屏幕应立即滚动。 先前的GIF显示了部分垂直滚动的示例。 屏幕的一部分保持静止(界面元素),另一部分(游戏区域)垂直滚动。 PPU的“标准”工作无法实现部分垂直滚动。
相反,完全定义了部分
水平滚动并且是可能的。
在绘制帧时写入单独的PPU寄存器可能会导致图形失真。 塞尔达传说(Legend of Zelda)故意造成一种工件,该工件表现为部分垂直滚动。 在本文中,我将讨论NES图形硬件,并说明垂直滚动技巧的工作原理。
图形类型
NES控制台具有两种类型的图形:
- 子画面是可以放置在屏幕上任意位置且彼此独立移动的图块。
- 背景-可以平滑滚动为单个图像的瓷砖网格。
为了演示两者之间的区别,我将展示一个由精灵和背景组成的场景:
这是只显示精灵的同一场景:
这是一个只有背景可见的场景:
卷动
图像处理器(NES图片处理器)支持滚动背景图像。 在图形存储器中,背景图被存储为一个二维的图块网格,覆盖了两倍于屏幕宽度和高度的区域。
在该网格中的屏幕上,屏幕上会显示一个“窗口”,大小与屏幕大小相同,并且可以精确控制该窗口的位置。 通过沿网格逐渐移动可见窗口,可以创建平滑的滚动效果。
输出的NES视频信号的大小为256x240像素。 存储器内部的图块网格表示为512x480像素区域,并分为四个屏幕大小的矩形,称为“名称表”。 游戏可以通过在名称表的网格中选择像素坐标来指示可见窗口的位置,从而配置图片处理单元(PPU)。
选择坐标(0,0)时,名称的整个左上表将显示在屏幕上:
转到(125,181),我们将从每个名称表中看到一些内容:
可见窗口最小化到内存中图块网格的背面。 移至(342,290),我们将可见屏幕的左上角放在名称的右下方表中,由于折叠,每个名称表的部分都将可见:
内存不足!
每个名称表的大小为1 KB,但是NES仅向这些表分配2 KB的视频内存,因此一次只能在内存中容纳两个名称表。
它怎么会有四个名称表?
镜像名称表
视频存储器以这样的方式连接到PPU:当PPU渲染四个表观名称表之一的图块时,实际上是两个真实表之一被选择,并且从那里进行读取。 从本质上讲,这意味着四个可见名称表实际上由两对相同的表组成。
此图显示了所有四个表的内容的快照。 左上方和右上方与两个下方相同。
为什么然后不保留两个名称表呢?
幸运的是,可以在运行时配置视在表和真实表之间的确切绑定。 如果游戏要执行水平滚动,则它会调整图形设备,以使左上和右上表不同,并且可以滚动它们而不会出现明显的重复。 在此配置中,左上表和左下表将引用相同的实名表; 同样适用于两个右表。 此配置称为垂直镜像。
还有另一种可能的配置-“水平镜像”,游戏将其用于垂直滚动。
通常,游戏不会沿对角线滚动,因为由于名称表的镜像,游戏会在屏幕边缘周围产生伪像。
弹药筒
每个游戏卡带都有用于配置表镜像的硬件。
有些游戏根本不需要切换镜像,因此水平或垂直镜像都硬编码在盒中。 其他游戏会在这两种模式之间动态切换,因此可以通过编程方式配置其弹药筒中的镜像。 塞尔达传说属于第二类。 最后,某些真正复杂的游戏的盒带具有额外的视频内存,也就是说,它们根本不需要镜像:它们可以同时垂直和水平滚动,而没有可见的复制伪像。
真实的例子
屏幕上显示的垂直滚动的示例。这显示了具有水平镜像的名称表的记录。 当前可见的窗口突出显示。请记住,最垂直的滚动并非罕见-异常的是带有
分屏的垂直滚动。
分屏
由NES生成的视频信号的每一帧都是从上到下渲染的,一次渲染一行像素。 在每一行中,从左到右一次绘制一个像素。 在渲染帧的一半时,游戏可以重新配置PPU,这会影响尚未渲染的像素的显示。 框架中间最常见的变化之一是更新水平滚动位置。
在房间之间水平滚动时,《塞尔达传说》始终从滚动位置(0,0)开始,并在屏幕顶部呈现界面元素。 在屏幕上绘制界面像素的最后一行后,水平滚动改变的值会随着每帧的增加而增加,因此相机会平稳移动。
名称表显示的动画显示了滚动之前游戏如何从水平镜像切换到垂直镜像,以及在过渡完成后如何再次切换到水平镜像。 另外,在继续滚动的同时,将更新左上(和左下)名称表,并在其中记录玩家进入的房间的副本。 滚动完成后,游戏停止拆分屏幕,并且再次完全从左上方的表格进行渲染。
渲染测量
为了将屏幕拆分到所需位置,游戏需要以某种方式找出绘制当前帧的哪一部分。 像素字符串以已知的频率渲染,因此可以通过计算自帧开始以来经过的处理器周期数来确定渲染的像素字符串的数量。
还有另一种更准确的技术,称为“精灵零击中”。
NES一次最多可以渲染64个精灵。 视频存储器中的第一个精灵称为“零精灵”(零精灵)。 在每帧中,只要将零子画面的不透明像素叠加到不透明的背景像素上,就会发生“子画面零命中”事件。 它通过内存映射将一个PPU寄存器中的一个位置1,可以由处理器检查。
要使用Sprite Zero Hit(Sprite零命中)来拆分屏幕,游戏会将零Sprite放置在拆分边界附近的垂直位置,并且在渲染过程中,它们会不断检查是否发生了Sprite Zero Hit事件。 如果是这样,则游戏将从水平滚动切换为实现分离。
带有和不带有背景的房间之间的水平过渡如下所示。
在过渡开始时出现并在结束时消失的棕色圆圈是零子图形。 我们将仔细研究有无背景的界面:
零精灵是与游戏界面中常规炸弹精灵完全匹配的漂白炸弹精灵。 零子画面被配置为显示在背景下,但是由于界面的黑色像素被认为是透明的,因此如果零子画面炸弹没有被战略性地隐藏在界面的炸弹后面,则该零子弹将是可见的。
请注意,“精灵零点击”发生在界面底行之前几行像素。 它发生在炸弹保险丝的顶部像素,距离界面底部16个像素。 当“精灵零命中”发生时,游戏开始计算处理器周期,并在完成所需的周期数后设置水平滚动。
光束消隐
大多数情况下,控制台PPU在屏幕上绘制像素。 帧之间的停机时间很短,在此期间不执行渲染。 这种现象称为消隐(垂直消隐或vblank)。 某些类型的PPU配置更改只能在vblank期间进行。
滚动寄存器
游戏通过写入称为
PPUSCROLL
的PPU寄存器来更改滚动位置,该寄存器映射到内存地址
0x2005
。
PPUSCROLL
的第一个写操作定义了滚动位置的X分量,第二个操作设置了Y分量,类似地,进一步执行交替记录。
下图显示了在该回放(慢动作)过程中屏幕的16帧中
PPUSCROLL
中所有非零的写入操作以及游戏的情节。 滚动位置分量Y每两帧增加一次。 在此示例中,
PPUSCROLL
中的所有写操作都是在vblank期间执行的,这会导致整个背景随之滚动。
滚动画面分割
在
PPUSCROLL
期间对
PPUSCROLL
写操作在vblank之后立即绘制的帧的开头生效。 如果滚动位置在帧渲染期间发生更改(即在vblank期间未发生变化),则此更改在图形到达下一行像素时生效。 通过在PPU在滚动之前绘制最后一行像素的同时写入
PPUSCROLL
来实现部分水平滚动。
当更新框架中间的滚动位置时,仅应用滚动位置的X位置。 即,滚动位置分量Y被丢弃。 因此,如果游戏要分割屏幕并更改框架滚动部分的位置,则只能水平滚动。
而且:
信不信由你,
PPUSCROLL
寄存器的值在此转换期间未更改。
您可以在界面下看到一个高1像素的图形工件。 这是我的模拟器的一个错误,这是由于处理器时钟周期与逐像素渲染缺乏同步所导致的。
干预其他名册
映射到内存地址
0x2006
的第二个寄存器称为
PPUADDR
,用于设置当前视频内存地址。 例如,当游戏想要更改名称表中的图块之一时,它首先将
PPUADDR
的视频内存地址写入
PPUADDR
,然后将
PPUDATA
的新值写入
PPUDATA
这是映射到地址
0x2007
的第三个寄存器。
PPUADDR
期间(即渲染帧时)写入
PPUADDR
可能会导致图形失真。 这是因为在从视频存储器获取图块以对其进行绘制的过程中,受
PPUADDR
写入影响的PPU链也直接受到PPU设备的控制。 由于渲染到屏幕的过程是从该行的顶部到底部,以及从左到右执行的,因此PPU本质上为
PPUADDR
分配
PPUADDR
绘制的当前
PPUADDR
的地址值。 当渲染从一个图块移动到另一个图块时,
PPUADDR
将增加当前值。
因此,在帧的中间写入
PPUADDR
可以更改PPU在当前帧的持续时间内从内存中接收到的图块。
让我们在垂直跳转期间
PPUADDR
对
PPUADDR
写操作。 由于名称表也在过渡期间更新,因此对
PPUADDR
的
所有写操作的输出将过于庞大。 在水平过渡的情况下,在渲染一行像素63时设置了滚动,因此,我们将仅在此行中考虑在
PPUADDR
写入操作。
图案清晰可见。 每两帧,记录在像素行63中的地址减少32(0x20)。 但是,这如何导致实际滚动位置的更新?
实际滚动寄存器
PPU内部有一个15位寄存器,未映射到CPU。 它既用作访问视频内存的当前地址,又用作后台滚动配置。
当将此值用作地址时,将忽略第14位,而将第0-13位视为视频存储器中的地址。
使用此值作为滚动配置时,其不同部分具有不同的含义:
选择一个名称表是一个介于0到3之间的值,该值确定用来绘制图形的当前名称表。
X中的 粗略滚动和Y中的粗略滚动确定所选名称表中图块的坐标。 这是当前要绘制的图块。
沿Y的精确滚动包含一个0到7的值,该值确定当前图块内部像素线的当前垂直偏移量。 瓦片是边长为8像素的正方形。
该寄存器中没有在
X上的精确滚动 。 有一个单独的寄存器,仅包含当前像素的水平偏移,但是对于解释《塞尔达传说》中如何执行垂直滚动并不重要。
游戏写入
PPUADDR
时,此寄存器会发生什么情况? 这是上面演示中的前三个写操作。
通过将地址中的条目分为滚动组件,您可以清楚地了解此处发生的情况。 每两帧,
Y中的“
粗略滚动”的值减小,从而导致垂直滚动一格或8像素。
在整个帧中,初始滚动偏移为0.0,此后在该地址上在像素线63上进行记录。 这意味着从包含界面背景的所选名称表的顶部开始绘制前63行像素。 但是,从该地址开始垂直滚动会进一步渲染第64行像素。 由于垂直滚动每两帧减少一次,因此可以感觉到部分屏幕的垂直滚动。
向下滚动以向上滚动
《塞尔达传说》无法完全对玩家隐藏这一招。 它会在屏幕的垂直过渡上创建可见的伪像,如果您仔细观察的话会很明显。 在房间之间移动时,滚动动画的第一帧将向下滚动。 这是慢动作的动画。
在名称表中,您可以看到实际发生的情况。 尽管在玩家看来,可见区域将平滑向上滚动,但滚动过渡是通过将可见区域从名称的左上表移动到包含房间背景副本的左下表开始的。 这是必需的,因为屏幕顶部的界面也是名称表的一部分,并且如果可见区域从其原始位置向上滚动,它将通过界面。
垂直滚动是通过写入帧中间的
PPUADDR
寄存器实现的。 要写入的第一个值是
0x2800
。 两帧后,
0x23A0
记录
0x23A0
,然后该值开始每第二帧减少32。
将值
0x2800
写入
0x2800
寄存器
0x2800
PPUADDR
表 PPUADDR
为2,这将呈现左下方的名称表。 由于两个滚动值均为0,因此它将从此名称表的左上图块开始。 但是,
Y中的“精确滚动”为2,因此距左下角名称表的顶部有两个像素的垂直偏移。 这就是为什么在过渡的第一帧中,我们在屏幕底部看到一个2像素高的黑条。 过渡动画的初始滚动值向下移动2个像素,以使过渡无缝。
两帧后,将
PPUADDR
写入
0x23A0
。 这将我们带回到名称的左上表,并从第29行图块(即底部)进行渲染。
在Y中精确滚动仍包含2。
为什么必须
将“精确滚动”设置为2? 为什么游戏不只写
0x0800
和
0x03A0
以免遭受两个像素的偏移?
四个名称表从
0x2000
到
0x2FFF
占据了PPU地址空间中4 KB的区域。 该表中的每个图块都占用一个视频字节的内存(实际上,它们只是另一个表中的索引),并且图块和名称表在视频内存中的顺序是这样的:
选择名称表, 按Y进行
粗略滚动和
按X进行
粗略滚动构成了图块内部的偏移量具有名称表的存储区。 也就是说,取内部PPU寄存器的低12位并将它们加到
0x2000
,您可以在视频存储器中找到
0x2000
地址。 这不是巧合! 这正是应该处理该寄存器的方式:既作为地址寄存器又作为滚动寄存器。
但是有一个缺陷。
当作为地址寄存器处理时,位12和13被认为是地址的一部分。 在渲染期间,PPU会不断用当前渲染图块的地址覆盖寄存器。 由于图块位于名称表中,并且这些表位于从
0x2000
到
0x2FFF
的存储区中,因此PPU将从此间隔中的值分配给寄存器。
当游戏在帧的中间写入
PPUADDR
时,如果它没有在名称表中写下平铺地址,则PPU将尝试
从视频内存中的
其他位置读取。 他碰巧要计数的任何字节都将被视为图块,这很可能导致不良结果。 因此,记录在
PPUADDR
帧中间的所有值
PPUADDR
必须在
0x2000
到
0x2FFF
范围内。 考虑此间隔中的每个数字并考虑其滚动成分,
Y中的“
精确”滚动值应始终等于2。
此限制意味着我们无法在帧中间更改
Y方向上的
精确滚动 ,即使用此技巧实现屏幕分离的垂直滚动时,我们仅限于一次滚动8个像素,并且始终与图块边框之间有两个像素的垂直偏移量。 《塞尔达传说》在水平滚动时每帧移动4个像素,在垂直滚动时每帧移动8像素,现在我们知道为什么。
在各个房间之间向下滚动时,该伪像也很明显,但是在这种情况下,它出现在动画的结尾。
补充阅读
注意事项
在我发现PPU的内部寄存器之前,我的仿真器展示了在《塞尔达传说》屏幕垂直过渡期间擦除的效果。
Link的sprite确实应在屏幕上向下移动,但背景并未滚动。 删除是由于游戏逐渐更新名称表,使其包含新游戏室的图形,但并未更新滚动条以使更新不显示在屏幕上。