В этой части рассмотрим работу с графикой: фон и спрайты персонажей.
<<< предыдущая следующая >>>

image
Источник


Что такое V-blank?


PPU — графический процессор — может или отправлять сигнал в телевизор, или получать информацию от процессора, но не одновременно. Так что единственное время для пересылки это V-blank, период кадрового гасящего импульса.


90% времени PPU отправляет пиксели в видеовыход, строка за строкой слева направо и сверху вниз. Внизу экрана делается пауза, и все повторяется снова. Это происходит 60 раз в секунду. Пауза после отрисовки кадра и есть V-blank. Это весьма короткий промежуток времени. В него реально вложить обновление 2-4 столбцов фоновых тайлов и обновление спрайтов. Обновление фона особенно критично для игр с прокруткой.


Есть два способа узнать о наступлении V-blank. Во-первых, PPU выставляет флаг в старшем бите регистра $2002. Во-вторых, вызывается немаскируемое прерывание (NMI), и происходит переход по заданному адресу — вектору прерывания. Его можно использовать для работы с PPU, контроля таймингов музыки, перемещения спрайтов. Интервал между вызовами NMI составляет 1/60 секунды, и его можно использовать для отсчета времени в игре.


Сейчас мы напишем программу, которая будет выводить “HELLO WORLD” по 1 букве каждые 30 кадров. После вывода всей фразы очистим экран, и повторим все по кругу.


Обратите внимание на фрагмент


nmi:
    inc _NMI_flag
    inc _Frame_Count

в файле reset.s. Это код обработчика немаскируемого прерывания, который фактически реализует счетчик кадров в глобальной переменной. Каждый кадр выставляет флаг NMI_flag, и в случае если прошло 30 кадров (0.5 секунды), выводится спрайт очередной буквы. Вся логика реализована в main().


lesson2.c
void Load_Text(void) {
 if (Text_Position < sizeof(TEXT)){
  //строка еще не выведена
  PPU_ADDRESS = 0x21;    // Y-координата строки
  PPU_ADDRESS = 0xca + Text_Position; //X-координата с учетом смещения
  PPU_DATA = TEXT[Text_Position];
  ++Text_Position; 
 }
 else {
  //все вывели, заливаем экран пустым нулевым тайлом
  Text_Position = 0;
  PPU_ADDRESS = 0x21;
  PPU_ADDRESS = 0xca;
  for ( index = 0; index < sizeof(TEXT); ++index ){
   PPU_DATA = 0; // при выводе в течении одного кадра позиция тайлов автоинкрементируется
  }
 }
}
void main (void) {
 All_Off(); // гасим экран
 Load_Palette();
 Reset_Scroll();
 All_On();  // включаем экран

 while (1){ // бесконечный цикл
  while (NMI_flag == 0); // ждем NMI
  NMI_flag = 0;
  if (Frame_Count == 30){ // прошло 30 кадров
   Load_Text();
   Reset_Scroll();
   Frame_Count = 0;
  }
 }
}

// Обработчик NMI делает ++NMIflag и ++FrameCount при каждом V-blank

image


Исходный код здесь и здесь.


В идеале, надо ждать V-blank перед вызовом All_On(), иначе один кадр после него будет будет искажен, и экран будет мерцать. В этом примере этот эффект незаметен, потому что All_On() вызывается единственный раз при старте приставки, экран в это время черный, и искажение незаметно.


Немного цвета


Теперь можно раскрасить то что получилось.


Пару слов о палитре в NES. В памяти PPU под нее выделено 16 байт по адресам $3F00-$3F0F для 4 палитр фона и 16 байт по адресам $3F10-$3F1F для 4 палитр спрайтов. Первый цвет спрайта всегда отрисовывается как прозрачный, а первый цвет фона — как цвет фона по умолчанию. Байты в памяти PPU, соответствующие первым цветам, зазеркалированы для всех 8 записей, и соответствуют цвету фона по умолчанию. Так что мы можем использовать 3 уникальных цвета на спрайт и 3 цвета плюс общий фоновый для тайлов.


image


Таким образом можно одновременно отрисовать 25 цветов из 50 возможных.


image


Можно затемнить эти цвета через регистры color emphasis, но эта функция совместима не со всеми клонами приставки. Кроме того, некоторым телевизорам срывает крышу от цветов $0D и $1D. Они получаются чернее черного, а это нештатная ситуация. Используйте цвета $xF.


Для фона палитра определяется для блока из 2х2 тайлов, размером 16х16 точек. Для этой разбивки удобно использовать Nes Screen Tool. Большинство игр строит фон именно из таких счетверенных метатайлов.


В каждой таблице имен (фактически, заготовке для рендеринга экрана) выделено 64 байта под атрибуты. Например, для таблицы 0 используются адреса PPU $2000-$23FF, а атрибуты хранятся в $23C0-$23FF. Каждый байт атрибутов описывает 4 метатайла, то есть область 32х32 пикселя. Соответственно, 2 бита выбирают номер палитры для метатайла. Соответствие бита метатайлу такое:


image
Здесь одна буква соответствует одному тайлу, малый квадрат — метатайл 2х2.


То есть чтобы поменять палитру правого нижнего квадранта на первую из 4 возможных, надо поменять два старших бита атрибута на 01: 01хххххх.


Я добавил к прошлому примеру палитры, так что буквы тепеь разноцветные. Палитры вынесены в отдельный файл и импортируются через #include, а переменные определены в нулевой странице памяти через #pragma — просто для демонстрации этой фичи. Нулевая страница работает быстрее, но 10-20 переменных там используется для внутренних нужд компилятора. Так что надо рассчитывать где-то на 235 доступных байт.


#pragma bss-name(push, "ZEROPAGE")
//здесь определяются переменные
#include "PALETTE.c"
#include "CODE.c"

lesson3.c
const unsigned char PALETTE[]={
0x11, 0x00, 0x00, 0x31, // синий
0x00, 0x00, 0x00, 0x15, // красный
0x00, 0x00, 0x00, 0x27, // желтый
0x00, 0x00, 0x00, 0x1a, // зеленый
}; 
// 0х11 - это стандартный фон, синий
// используются только палитры для фона

const unsigned char Attrib_Table[]={
0x44, // 0100 0100, 
0xbb, // 1011 1011, 
0x44, // 0100 0100, 
0xbb}; // 1011 1011 };
// таблица атрибутов - 2 бита указывают номер палитры для метатайла 16х16 точек

 PPU_ADDRESS = 0x23;
 PPU_ADDRESS = 0xda;
 for( index = 0; index < sizeof(Attrib_Table); ++index ){
  PPU_DATA = Attrib_Table[index];
  } // пересылка таблицы атрибутов в PPU

image


Исходный код здесь и здесь.


Вот полезная шпаргалка по адресам таблицы атрибутов и ее привязке к экранным координатам:
image
Экран шириной 256 точек, каждая клетка таблицы соответствует квадрату 32х32


Спрайты


Спрайт — это картинка 8х8, которая может перемещаться по экрану. Есть хитрый способ использовать спрайты 8х16, но мы так делать не будем. Почти все персонажи игр состоят из спрайтов. Хотя в некоторых случаях нужно отрисовывать их фоновыми тайлами из-за ограничений на количество спрайтов, такое используется для финальных боссов в некоторых играх.
8х8 точек это очень мало, поэтому придется собирать персонажа из нескольких спрайтов: маленького Марио из 2х2, а большого из 2х4.


image


Надо помнить об ограничениях. Поддерживается не больше 64 спрайтов, и не более 8 на одной строке экрана. При превышении лимита будут отрисованы только спрайты с большим приоритетом. Если менять приоритет спрайта между кадрами, он будет мигать. Этот способ часто используется в играх.


PPU хранит информацию о спрайтах в таблице OAM. Ее размер 256 байт: по 4 байта на каждый из 64 возможных спрайтов. Если накладываются два спрайта с одинаковым приоритетом, то спрайт с меньшим номером отрисовывается поверх. Если на одной строке экрана будет больше 8 спрайтов, то отрисуются только 8 с меньшими номерами.


Таблица OAM хранит такие атрибуты:


  1. Y-координату
  2. Индекс спрайта, который надо отобразить
  3. Атрибуты: палитра, приоритет, зеркальное отражение
  4. X-координату

Координаты считаются по левому верхнему углу. X может быть от $00 до $F8, Y от $00 до $EE. Можно частично спрятать спрайт вниз или вправо от экрана, но левая и верхняя границы неприкосновенны. В нашем примере код инициализации прячет спрайты внизу, Y=$F8.
Подробности по таблице OAM.


Как все это хранится в памяти:
image
Правда здесь таблица спрайтов хранится в RAM по адресу $700, а в примере используется $200.


Запись в таблицу OAM реализована через регистры памяти: надо записать адрес спрайта в $2003, а потом данные для него в $2004. Этим редко пользуются, потому что есть более удобный и быстрый способ заливки через DMA. Он активируется через регистр $4014: пишем $xx по адресу $4014, 256 байт из диапазона $xx00-$xxFF сами заливаются в OAM. Это надо делать в период V-Blank.


В нашем примере мы сделаем метаспрайт из 4 спрайтов и добавим анимацию.


Надо помнить, что спрайты отрисовываются на 1 точку ниже от ожидаемой координаты. На картинке с Марио вверху поста видно, что он на 1 пиксель ушел в пол.


lesson4.c

#pragma bss-name(push, "ZEROPAGE")

unsigned char NMI_flag;
unsigned char Frame_Count;
unsigned char index;
unsigned char index4;
unsigned char X1;
unsigned char Y1;
unsigned char move;
unsigned char move_count;
#pragma bss-name(push, "OAM")
unsigned char SPRITES[256];
// OAM лежит по адресам $200-$2FF, это определено в файле cfg
const unsigned char PALETTE[]={
0x19, 0, 0, 0,  0, 0, 0, 0,  0, 0, 0, 0,  0, 0, 0, 0,
0x19, 0x37, 0x24, 1,  0, 0, 0, 0,  0, 0, 0, 0,  0, 0, 0, 0};
const unsigned char MetaSprite_Y[] = {0, 0, 8, 8}; // относительные Y-координаты
const unsigned char MetaSprite_Tile[] = {0, 1, 0x10, 0x11}; // номера тайлов
const unsigned char MetaSprite_Attrib[] = {0, 0, 0, 0}; // атрибуты: отражение и номер палитры
const unsigned char MetaSprite_X[] = {0, 8, 0, 8}; // относительные X-координаты
// Используем 4 спрайта, каждый смещен от левого-верхнего нулевого спрайта
void every_frame(void) {

 OAM_ADDRESS = 0;
 OAM_DMA = 2; // Запись данных спрайтов в OAM через DMA
 PPU_CTRL = 0x90; // экран и NMI включены
 PPU_MASK = 0x1e; 
 SCROLL = 0;
 SCROLL = 0;  // перепроверим, что прокрутка выключена
}
// код для автоизменения положения спрайтов
void update_Sprites (void) {
 index4 = 0;
 for (index = 0; index < 4; ++index ){
  SPRITES[index4] = MetaSprite_Y[index] + Y1; // сдвиг по вертикали
  ++index4;
  SPRITES[index4] = MetaSprite_Tile[index]; // номера тайлов
  ++index4;
  SPRITES[index4] = MetaSprite_Attrib[index]; // атрибуты не меняются
  ++index4;
  SPRITES[index4] = MetaSprite_X[index] + X1; // сдвиг по горизонтали
  ++index4;
 }
} // код неэффективный, но наглядный

void main (void) {
 All_Off(); // гасим экран
 X1 = 0x7f; // начальная координата спрайтов
 Y1 = 0x77; // в районе центра экрана
 Load_Palette();
 Reset_Scroll();
 All_On(); // включаем экран
 while (1){ // бесконечный цикл
  while (NMI_flag == 0); // пока не сработает NMI
  NMI_flag = 0;
  every_frame(); // экран надо обновить при каждом v-blank
  if (move == 0) ++X1;
  if (move == 1) ++Y1;
  if (move == 2) --X1;
  if (move == 3) --Y1;
  ++move_count;
  if (move_count == 20){ // движемся 20 кадров
   move_count = 0;
   ++move;
  }
  if (move == 4) move=0;
  update_Sprites();
 }
}

Есть немного улучшенная версия, где персонаж поворачивается при движении по квадрату.


Фрагмент lesson4b.c
const unsigned char MetaSprite_Tile[] = { //дополнительные тайлы
 2, 3, 0x12, 0x13, // right
 0, 1, 0x10, 0x11, // down
 6, 7, 0x16, 0x17, // left
 4, 5, 0x14, 0x15}; // up
void update_Sprites (void) {
 move4 = move << 2; // Быстрое умножение на 4
 index4 = 0;
 for (index = 0; index < 4; ++index ){
  SPRITES[index4] = MetaSprite_Y[index] + Y1; // сдвиг по вертикали
  ++index4;
  SPRITES[index4] = MetaSprite_Tile[index + move4]; // номера тайлов
  ++index4;
  SPRITES[index4] = MetaSprite_Attrib[index]; // атрибуты не меняются
  ++index4;
  SPRITES[index4] = MetaSprite_X[index] + X1; // сдвиг по горизонтали
  ++index4;
 }
}

image


Исходный код здесь и здесь.

Комментарии (2)


  1. dalex713
    12.02.2018 11:08

    Спасибо


  1. Mefistotel
    12.02.2018 13:50

    Очень здорово переведено. Это лучшее, что есть в сети по разработке игр на NES на русском языке. Желаю вам полностью добить начатое!