将 GameBoy 模拟器的形状放在一起,并在 CPU 和图形处理器之间建立了时序。画布已被初始化并准备好由模拟的 GameBoy 绘制图形;GPU 仿真现在具有结构,但仍无法将图形渲染到帧缓冲区。为了让仿真渲染图形,必须简要检查 GameBoy 图形背后的概念。
概述
就像那个时代的大多数游戏机一样,GameBoy 没有足够的内存来允许将直接帧缓冲区保存在内存中。取而代之的是,使用了一个平铺系统:一组小位图保存在内存中,并对这些位图进行引用来构建地图。这个系统的先天优势是一个图块可以通过地图重复使用。
GameBoy 的图块图形系统以 8x8 像素的图块运行,一张地图可以使用 256 个独特的图块;内存中可以保存两张 32x32 图块的地图,其中一张可以一次用于显示。GameBoy 内存中有 384 个图块的空间,因此其中一半在地图之间共享:一张地图使用 0 到 255 之间的图块编号,另一张使用 -128 到 127 之间的数字作为其图块。
在视频内存中,瓦片数据和地图的布局如下运行。
显存的布局
区域 | 用法 |
---|---|
8000-87FF | Tile set #1: tiles 0-127 |
8800-8FFF | Tile set #1: tiles 128-255 Tile set #0: tiles -1 to -128 |
9000-97FF | Tile set #0: tiles 0-127 |
9800-9BFF | Tile map #0 |
9C00-9FFF | Tile map #1 |
定义背景后,其地图和图块数据会相互作用以生成图形显示:
背景映射
如前所述,背景图是 32x32 的图块;这是 256 x 256 像素。GameBoy 的显示为 160x144 像素,因此背景可以相对于屏幕移动。GPU 通过在背景中定义一个对应于屏幕左上角的点来实现这一点:通过在帧之间移动这个点,背景在屏幕上滚动。为此,左上角的定义由两个 GPU 寄存器保存:Scroll X 和 Scroll Y。
背景滚动寄存器
调色板
GameBoy 通常被描述为单色机器,只能显示黑白。这并不完全正确:GameBoy 还可以处理浅灰色和深灰色,总共四种颜色。在图块数据中表示这四种颜色中的一种需要两位,因此图块数据集中的每个图块都以 (8x8x2) 位或 16 个字节表示。
GameBoy 背景的另一个复杂之处是在瓷砖数据和最终显示之间插入了一个调色板:瓷砖像素的四个可能值中的每一个都可以对应于四种颜色中的任何一种。这主要用于让瓷砖集轻松更改颜色;例如,如果一组拼贴对应于英文字母,则可以通过更改调色板来构建反视频版本,而不是占用拼贴组的另一部分。通过更改背景调色板 GPU 寄存器的值,四个调色板条目同时更新;使用的颜色参考和寄存器的结构如下所示。
颜色参考值
值 | 像素 | 仿真颜色 |
---|---|---|
0 | 关闭 | [255, 255, 255] |
1 | 33% 开启 | [192, 192, 192] |
2 | 66% 开启 | [96, 96, 96] |
3 | 全开启 | [0, 0, 0] |
实现:瓦片数据
如上所述,瓦片数据集中的每个像素由两位表示:当在地图中引用瓦片时,这些位由 GPU 读取,穿过调色板并推送到屏幕。GPU 的硬件是这样接线的,即可以同时访问整行瓦片,并且像素通过运行位循环。唯一的问题是瓦片的一行是两个字节:由此产生了稍微复杂的位存储方案,其中每个像素的低位保存在一个字节中,而高位保存在另一个字节中。
平铺数据位图结构
由于 JavaScript 不是快速操作位图结构的理想选择,因此处理 tile 数据集最省时的方法是在视频内存旁边维护一个内部数据集,并具有更扩展的视图,其中每个像素的值已预先确定计算。为了准确反映瓦片数据集,对视频 RAM 的任何写入都必须触发更新 GPU 内部瓦片数据的函数。
GPU.js的源码
GPU = {
_vram: [],
_oam: [],
_reg: [],
_tilemap: [],
_objdata: [],
_objdatasorted: [],
_palette: {'bg':[], 'obj0':[], 'obj1':[]},
_scanrow: [],
_curline: 0,
_curscan: 0,
_linemode: 0,
_modeclocks: 0,
_yscrl: 0,
_xscrl: 0,
_raster: 0,
_ints: 0,
_lcdon: 0,
_bgon: 0,
_objon: 0,
_winon: 0,
_objsize: 0,
_bgtilebase: 0x0000,
_bgmapbase: 0x1800,
_wintilebase: 0x1800,
reset: function() {
for(var i=0; i<8192; i++) {
GPU._vram[i] = 0;
}
for(i=0; i<160; i++) {
GPU._oam[i] = 0;
}
for(i=0; i<4; i++) {
GPU._palette.bg[i] = 255;
GPU._palette.obj0[i] = 255;
GPU._palette.obj1[i] = 255;
}
for(i=0;i<512;i++)
{
GPU._tilemap[i] = [];
for(j=0;j<8;j++)
{
GPU._tilemap[i][j] = [];
for(k=0;k<8;k++)
{
GPU._tilemap[i][j][k] = 0;
}
}
}
LOG.out('GPU', 'Initialising screen.');
var c = document.getElementById('screen');
if(c && c.getContext)
{
GPU._canvas = c.getContext('2d');
if(!GPU._canvas)
{
throw new Error('GPU: Canvas context could not be created.');
}
else
{
if(GPU._canvas.createImageData)
GPU._scrn = GPU._canvas.createImageData(160,144);
else if(GPU._canvas.getImageData)
GPU._scrn = GPU._canvas.getImageData(0,0,160,144);
else
GPU._scrn = {'width':160, 'height':144, 'data':new Array(160*144*4)};
for(i=0; i<GPU._scrn.data.length; i++)
GPU._scrn.data[i]=255;
GPU._canvas.putImageData(GPU._scrn, 0,0);
}
}
GPU._curline=0;
GPU._curscan=0;
GPU._linemode=2;
GPU._modeclocks=0;
GPU._yscrl=0;
GPU._xscrl=0;
GPU._raster=0;
GPU._ints = 0;
GPU._lcdon = 0;
GPU._bgon = 0;
GPU._objon = 0;
GPU._winon = 0;
GPU._objsize = 0;
for(i=0; i<160; i++) GPU._scanrow[i] = 0;
for(i=0; i<40; i++)
{
GPU._objdata[i] = {'y':-16, 'x':-8, 'tile':0, 'palette':0, 'yflip':0, 'xflip':0, 'prio':0, 'num':i};
}
// Set to values expected by BIOS, to start
GPU._bgtilebase = 0x0000;
GPU._bgmapbase = 0x1800;
GPU._wintilebase = 0x1800;
LOG.out('GPU', 'Reset.');
},
checkline: function() {
GPU._modeclocks += Z80._r.m;
switch(GPU._linemode)
{
// In hblank
case 0:
if(GPU._modeclocks >= 51)
{
// End of hblank for last scanline; render screen
if(GPU._curline == 143)
{
GPU._linemode = 1;
GPU._canvas.putImageData(GPU._scrn, 0,0);
MMU._if |= 1;
}
else
{
GPU._linemode = 2;
}
GPU._curline++;
GPU._curscan += 640;
GPU._modeclocks=0;
}
break;
// In vblank
case 1:
if(GPU._modeclocks >= 114)
{
GPU._modeclocks = 0;
GPU._curline++;
if(GPU._curline > 153)
{
GPU._curline = 0;
GPU._curscan = 0;
GPU._linemode = 2;
}
}
break;
// In OAM-read mode
case 2:
if(GPU._modeclocks >= 20)
{
GPU._modeclocks = 0;
GPU._linemode = 3;
}
break;
// In VRAM-read mode
case 3:
// Render scanline at end of allotted time
if(GPU._modeclocks >= 43)
{
GPU._modeclocks = 0;
GPU._linemode = 0;
if(GPU._lcdon)
{
if(GPU._bgon)
{
var linebase = GPU._curscan;
var mapbase = GPU._bgmapbase + ((((GPU._curline+GPU._yscrl)&255)>>3)<<5);
var y = (GPU._curline+GPU._yscrl)&7;
var x = GPU._xscrl&7;
var t = (GPU._xscrl>>3)&31;
var pixel;
var w=160;
if(GPU._bgtilebase)
{
var tile = GPU._vram[mapbase+t];
if(tile<128) tile=256+tile;
var tilerow = GPU._tilemap[tile][y];
do
{
GPU._scanrow[160-x] = tilerow[x];
GPU._scrn.data[linebase+3] = GPU._palette.bg[tilerow[x]];
x++;
if(x==8) { t=(t+1)&31; x=0; tile=GPU._vram[mapbase+t]; if(tile<128) tile=256+tile; tilerow = GPU._tilemap[tile][y]; }
linebase+=4;
} while(--w);
}
else
{
var tilerow=GPU._tilemap[GPU._vram[mapbase+t]][y];
do
{
GPU._scanrow[160-x] = tilerow[x];
GPU._scrn.data[linebase+3] = GPU._palette.bg[tilerow[x]];
x++;
if(x==8) { t=(t+1)&31; x=0; tilerow=GPU._tilemap[GPU._vram[mapbase+t]][y]; }
linebase+=4;
} while(--w);
}
}
if(GPU._objon)
{
var cnt = 0;
if(GPU._objsize)
{
for(var i=0; i<40; i++)
{
}
}
else
{
var tilerow;
var obj;
var pal;
var pixel;
var x;
var linebase = GPU._curscan;
for(var i=0; i<40; i++)
{
obj = GPU._objdatasorted[i];
if(obj.y <= GPU._curline && (obj.y+8) > GPU._curline)
{
if(obj.yflip)
tilerow = GPU._tilemap[obj.tile][7-(GPU._curline-obj.y)];
else
tilerow = GPU._tilemap[obj.tile][GPU._curline-obj.y];
if(obj.palette) pal=GPU._palette.obj1;
else pal=GPU._palette.obj0;
linebase = (GPU._curline*160+obj.x)*4;
if(obj.xflip)
{
for(x=0; x<8; x++)
{
if(obj.x+x >=0 && obj.x+x < 160)
{
if(tilerow[7-x] && (obj.prio || !GPU._scanrow[x]))
{
GPU._scrn.data[linebase+3] = pal[tilerow[7-x]];
}
}
linebase+=4;
}
}
else
{
for(x=0; x<8; x++)
{
if(obj.x+x >=0 && obj.x+x < 160)
{
if(tilerow[x] && (obj.prio || !GPU._scanrow[x]))
{
GPU._scrn.data[linebase+3] = pal[tilerow[x]];
}
}
linebase+=4;
}
}
cnt++; if(cnt>10) break;
}
}
}
}
}
}
break;
}
},
updatetile: function(addr,val) {
var saddr = addr;
if(addr&1) { saddr--; addr--; }
var tile = (addr>>4)&511;
var y = (addr>>1)&7;
var sx;
for(var x=0;x<8;x++)
{
sx=1<<(7-x);
GPU._tilemap[tile][y][x] = ((GPU._vram[saddr]&sx)?1:0) | ((GPU._vram[saddr+1]&sx)?2:0);
}
},
updateoam: function(addr,val) {
addr-=0xFE00;
var obj=addr>>2;
if(obj<40)
{
switch(addr&3)
{
case 0: GPU._objdata[obj].y=val-16; break;
case 1: GPU._objdata[obj].x=val-8; break;
case 2:
if(GPU._objsize) GPU._objdata[obj].tile = (val&0xFE);
else GPU._objdata[obj].tile = val;
break;
case 3:
GPU._objdata[obj].palette = (val&0x10)?1:0;
GPU._objdata[obj].xflip = (val&0x20)?1:0;
GPU._objdata[obj].yflip = (val&0x40)?1:0;
GPU._objdata[obj].prio = (val&0x80)?1:0;
break;
}
}
GPU._objdatasorted = GPU._objdata;
GPU._objdatasorted.sort(function(a,b){
if(a.x>b.x) return -1;
if(a.num>b.num) return -1;
});
},
rb: function(addr) {
var gaddr = addr-0xFF40;
switch(gaddr)
{
case 0:
return (GPU._lcdon?0x80:0)|
((GPU._bgtilebase==0x0000)?0x10:0)|
((GPU._bgmapbase==0x1C00)?0x08:0)|
(GPU._objsize?0x04:0)|
(GPU._objon?0x02:0)|
(GPU._bgon?0x01:0);
case 1:
return (GPU._curline==GPU._raster?4:0)|GPU._linemode;
case 2:
return GPU._yscrl;
case 3:
return GPU._xscrl;
case 4:
return GPU._curline;
case 5:
return GPU._raster;
default:
return GPU._reg[gaddr];
}
},
wb: function(addr,val) {
var gaddr = addr-0xFF40;
GPU._reg[gaddr] = val;
switch(gaddr)
{
case 0:
GPU._lcdon = (val&0x80)?1:0;
GPU._bgtilebase = (val&0x10)?0x0000:0x0800;
GPU._bgmapbase = (val&0x08)?0x1C00:0x1800;
GPU._objsize = (val&0x04)?1:0;
GPU._objon = (val&0x02)?1:0;
GPU._bgon = (val&0x01)?1:0;
break;
case 2:
GPU._yscrl = val;
break;
case 3:
GPU._xscrl = val;
break;
case 5:
GPU._raster = val;
// OAM DMA
case 6:
var v;
for(var i=0; i<160; i++)
{
v = MMU.rb((val<<8)+i);
GPU._oam[i] = v;
GPU.updateoam(0xFE00+i, v);
}
break;
// BG palette mapping
case 7:
for(var i=0;i<4;i++)
{
switch((val>>(i*2))&3)
{
case 0: GPU._palette.bg[i] = 255; break;
case 1: GPU._palette.bg[i] = 192; break;
case 2: GPU._palette.bg[i] = 96; break;
case 3: GPU._palette.bg[i] = 0; break;
}
}
break;
// OBJ0 palette mapping
case 8:
for(var i=0;i<4;i++)
{
switch((val>>(i*2))&3)
{
case 0: GPU._palette.obj0[i] = 255; break;
case 1: GPU._palette.obj0[i] = 192; break;
case 2: GPU._palette.obj0[i] = 96; break;
case 3: GPU._palette.obj0[i] = 0; break;
}
}
break;
// OBJ1 palette mapping
case 9:
for(var i=0;i<4;i++)
{
switch((val>>(i*2))&3)
{
case 0: GPU._palette.obj1[i] = 255; break;
case 1: GPU._palette.obj1[i] = 192; break;
case 2: GPU._palette.obj1[i] = 96; break;
case 3: GPU._palette.obj1[i] = 0; break;
}
}
break;
}
}
};
分析
GPU.js:内部瓦片数据
_tileset: [],
reset: function()
{
// In addition to previous reset code:
GPU._tileset = [];
for(var i = 0; i < 384; i++)
{
GPU._tileset[i] = [];
for(var j = 0; j < 8; j++)
{
GPU._tileset[i][j] = [0,0,0,0,0,0,0,0];
}
}
},
// Takes a value written to VRAM, and updates the
// internal tile data set
updatetile: function(addr, val)
{
// Get the "base address" for this tile row
addr &= 0x1FFE;
// Work out which tile and row was updated
var tile = (addr >> 4) & 511;
var y = (addr >> 1) & 7;
var sx;
for(var x = 0; x < 8; x++)
{
// Find bit index for this pixel
sx = 1 << (7-x);
// Update tile set
GPU._tileset[tile][y][x] =
((GPU._vram[addr] & sx) ? 1 : 0) +
((GPU._vram[addr+1] & sx) ? 2 : 0);
}
}
MMU.js:贴片更新触发器
wb: function(addr, val)
{
switch(addr & 0xF000)
{
// Only the VRAM case is shown:
case 0x8000:
case 0x9000:
GPU._vram[addr & 0x1FFF] = val;
GPU.updatetile(addr, val);
break;
}
}
扫描渲染
有了这些部分,就可以开始渲染 GameBoy 屏幕了。由于这是逐行完成的renderscan,第 3 部分中提到的函数必须在渲染扫描线之前确定它在屏幕上的位置。这涉及使用滚动寄存器和当前扫描线计数器计算背景图中位置的 X 和 Y 坐标。一旦确定了这一点,扫描渲染器就可以通过地图那一行中的每个图块前进,在遇到每个图块时拉入新的图块数据。
GPU.js:扫描渲染
renderscan: function()
{
// VRAM offset for the tile map
var mapoffs = GPU._bgmap ? 0x1C00 : 0x1800;
// Which line of tiles to use in the map
mapoffs += ((GPU._line + GPU._scy) & 255) >> 3;
// Which tile to start with in the map line
var lineoffs = (GPU._scx >> 3);
// Which line of pixels to use in the tiles
var y = (GPU._line + GPU._scy) & 7;
// Where in the tileline to start
var x = GPU._scx & 7;
// Where to render on the canvas
var canvasoffs = GPU._line * 160 * 4;
// Read tile index from the background map
var colour;
var tile = GPU._vram[mapoffs + lineoffs];
// If the tile data set in use is #1, the
// indices are signed; calculate a real tile offset
if(GPU._bgtile == 1 && tile < 128) tile += 256;
for(var i = 0; i < 160; i++)
{
// Re-map the tile pixel through the palette
colour = GPU._pal[GPU._tileset[tile][y][x]];
// Plot the pixel to canvas
GPU._scrn.data[canvasoffs+0] = colour[0];
GPU._scrn.data[canvasoffs+1] = colour[1];
GPU._scrn.data[canvasoffs+2] = colour[2];
GPU._scrn.data[canvasoffs+3] = colour[3];
canvasoffs += 4;
// When this tile ends, read another
x++;
if(x == 8)
{
x = 0;
lineoffs = (lineoffs + 1) & 31;
tile = GPU._vram[mapoffs + lineoffs];
if(GPU._bgtile == 1 && tile < 128) tile += 256;
}
}
}
通过 CPU、内存处理和图形子系统,模拟器几乎能够产生输出。在第 5 部分中,我将研究将系统从一组不同的模块文件变成一个连贯的整体需要什么,能够加载和运行一个简单的 ROM 文件:将图形寄存器绑定到 MMU,以及一个简单的界面来控制仿真的运行。