Imran Nazar: Эмуляция GameBoy на JavaScript: Графика

Перевод четвёртой статьи про эмуляцию GameBoy на JavaScript. Оригинал здесь. Далее авторский текст.

Это четвёртая часть серии статей об эмуляции в JavaScript. Десять частей уже доступно, позже будут ещё.

В предыдущих частях мы определили общую форму эмулятора GameBoy и синхронизировали CPU с графическим процессором. Холст (canvas, всё же я решил перевести этот термин. прим. перев.) инициализирован и эмулируемый GameBoy готов на нём рисовать. Сейчас эмуляция GPU представляет собой только общую структуру и ещё не способна рендерить (я позволил себе достаточно вольно перевести render. прим. перев.) графику в экранный буфер (framebuffer). Перед тем как реализовывать рендеринг стоит вкратце ознакомиться с графической подсистемой GameBoy.

Фоны

GameBoy, как и большинство консолей того времени, не имеет достаточного количества памяти для хранения всего экранного буфера целиком. Вместо этого используется тайловая система. В памяти хранится набор небольших растровых изображений из ссылок на которые конструируется карта (map). Плюс такого подхода в том, что один тайл можно использовать на карте несколько раз, просто задействуя ссылку на него.

Тайловая графическая подсистема GameBoy оперирует тайлами 8×8 пикселей. Всего при построении карты может быть использовано 256 уникальных тайлов. В памяти может храниться две карты размером 32×32 тайла и одна из них может выводиться на экран. В памяти GameBoy есть место для 384 тайлов, поэтому половина их них используется совместно двумя картами: одна карта использует тайлы с номерами от 0 до 255, другая от -128 до 128.

В видеопамяти тайлы и карты располагаются следующим образом.

Область Использование
8000-87FF Набор тайлов #1: тайлы 0-127
8800-8FFF Набор тайлов #1: тайлы 128-255

Набор тайлов #0: тайлы -1 to -128

9000-97FF Набор тайлов #0: тайлы 0-127
9800-9BFF Карта тайлов #0
9C00-9FFF Карта тайлов #1

Таблица 1: Схема VRAM

Когда фон определён — его карта и тайлы взаимодействуют для создания итогового изображения:

Рис. 1: Конструирование фона

Как упоминалось ранее, размер карты фона составляет 32×32 тайла, что даёт размер 256 на 256 пикселей. Разрешение экрана GameBoy — 160×144 пикселей, поэтому у фона есть возможность перемещаться относительно экрана. Для достижения этого GPU определяет точку на изображении, которая соответствует верхнему левому углу экрана: фоновое изображение перемещается за счёт изменения позиции этой точки между кадрами. Для этой операции, позиция верхнего левого угла хранится в двух регистрах GPU: Scroll Х и Scroll Y.

Рис. 2: Регистры прокрутки фона

Палитра

GameBoy часто описывается как монохромное устройство способное отображать только белый и чёрный цвета. Это не совсем так: GameBoy также может отображать светло и тёмно серый. Итого — четыре цвета. Для хранения одного цвета требуется два бита, поэтому для хранения каждого тайла необходимо (8x8x2) бит или 16 байт.

Ещё одно усложнение фонов в GameBoy состоит в том, что между тайлами и финальным отображением вклинивается палитра: каждому из четырёх возможных значений пикселя тайла может соответствовать любой из четырёх цветов. (довольно бестолковая фраза как мне кажется. Проще говоря, цвет пикселя в тайле это не цвет на самом деле, а некий условный номер, который, затем, используя палитру, превращается в цвет. Соответственно, меняя палитру мы влияем на цвет сразу всех пикселей в тайле. прим. перев.) В основном это используется для того, чтобы обеспечить простую смену цвета для тайла, если, например, набор тайлов соответствует английскому алфавиту. инверсная версия может быть получена путём изменения палитры, без добавления новых тайлов. Четыре записи палитры обновляются одновременно путём изменения значения в регистре GPU Background Palette. Используемые ссылки на цвета и структура регистра показаны ниже.

Значение Пиксель Эмулируемый цвет
0 Отключён [255, 255, 255]
1 Яркость — 33% [192, 192, 192]
2 Яркость — 66% [96, 96, 96]
3 Яркость — 100% [0, 0, 0]

Таблица 2: Значения ссылок на цвета

Рис. 3: Регистр палитры фона

Реализация: тайлы

Как было замечено ранее, каждый пиксель в тайле представлен двумя битами: в случае, если в карте есть ссылка на тайл, то эти биты считываются GPU, прогоняются через палитру и выводятся на экран. GPU подключен таким образом, что единовременно доступна целая строка тайла. Недостаток такого подхода в том, что одна строка занимает два байта: в результате получается немного запутанная схема хранения битов, где младший бит пикселя хранится в одном байте, а старший в другом. (не забывайте, что шина данных у GameBoy — 8 бит, поэтому больше байта за раз протолкнуть не получится. прим. перев.)

Рис. 4: Структура растровых данных тайла

Так как JavaScript не очень подходит для быстрого манипулирования растровыми структурами, то наиболее эффективный по времени путь обработки тайлов это хранение данных в их оригинальном представлении одновременно с более расширенным, где значение каждого пиеселя предрасчитано. Для поддержания этих структур в актуальном состоянии любая запись в видео память должна вызывать функцию, которая будет обновлять предрассчитанные значения.

GPU.js: Предрассчитанные значения

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
    _tileset: [],

    reset: function()
    {
        // Дополнение к предыдущему коду сброса:
    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];
        }
    }
    },

    // Взять значение из видеопамяти и обновить
    // предрассчитанные пиксели
    updatetile: function(addr, val)
    {
        // Взять "базовый адрес" для этой строки тайла
    addr &= 0x1FFE;

    // Определить какой тайл и строка обновлены
    var tile = (addr >> 4) & 511;
    var y = (addr >> 1) & 7;

    var sx;
    for(var x = 0; x < 8; x++)
    {
        // Найти индекс бита этого пикселя
        sx = 1 << (7-x);

        // Обновить набор тайлов
        GPU._tileset[tile][y][x] =
            ((GPU._vram[addr] & sx)   ? 1 : 0) +
            ((GPU._vram[addr+1] & sx) ? 2 : 0);
    }
    }

MMU.js: Триггер обновления тайлов

1
2
3
4
5
6
7
8
9
10
11
12
    wb: function(addr, val)
    {
        switch(addr & 0xF000)
    {
            // Показан код только для видеопамяти:
        case 0x8000:
        case 0x9000:
        GPU._vram[addr & 0x1FFF] = val;
        GPU.updatetile(addr, val);
        break;
    }
    }

Реализация: Построчный рендеринг

Теперь можно приступить к рендерингу изображения на экран GameBoy. Функция renderscan из третьей статьи перед началом рендеринга линии должна определить её местоположение на экране. Это включает в себя расчёт X и Y координат позиции на фоне, используя регистры прокрутки и текущий счётчик отрендеренных линий. После определения этого рендерер может пройтись по каждому тайлу в строке на карте, считывания их них данные.

GPU.js: Построчный рендеринг

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
   renderscan: function()
    {
    // Смещение видеопамяти для этой карты тайлов
    var mapoffs = GPU._bgmap ? 0x1C00 : 0x1800;

    // Какую линию тайлов использовать в этой карте
    mapoffs += ((GPU._line + GPU._scy) & 255) >> 3;
   
    // С какого тайла начать в этой линии
    var lineoffs = (GPU._scx >> 3);

    // Какую линию пикселей использовать в этих тайлах
    var y = (GPU._line + GPU._scy) & 7;

    // С какого места в линии начать
    var x = GPU._scx & 7;

        // Где начать рисовать на холсте
    var canvasoffs = GPU._line * 160 * 4;

    // Прочитать индекс тайла из карты
    var colour;
    var tile = GPU._vram[mapoffs + lineoffs];

    // Если используется набор тайлов #1, то индексы имеют знак
    // Рассчитать настоящее смещение тайла
    if(GPU._bgtile == 1 && tile < 128) tile += 256;

    for(var i = 0; i < 160; i++)
    {
        // Получить конечный цвет при помощи палитры
        colour = GPU._pal[GPU._tileset[tile][y][x]];

        // Нанести пиксель на холст
        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;

        // После окончания текущего тайла взять следующий
        x++;
        if(x == 8)
        {
        x = 0;
        lineoffs = (lineoffs + 1) & 31;
        tile = GPU._vram[mapoffs + lineoffs];
        if(GPU._bgtile == 1 && tile < 128) tile += 256;
        }
    }
    }

Следующие шаги: Вывод

Процессор, управление памятью, графическая подсистема — эмулятор практически готов для вывода изображения. В пятой части я буду собирать разрозненный набор модулей в единое целое, которое сможет загрузить и запустить на выполнение простой ROM. Для этого потребуется привязать графические регистры к MMU и создать простой интерфейс для контроля над запущенным процессом эмуляции.

Imran Nazar , Август 2010.