Подготовка к выводу изображений
В прошлый раз мы написали интерпретатор CHIP-8, который способен выполнять все операции за исключением одной
Dxyn
(DRW Vx, Vy, nibble
). Ради упрощения реализации этой
инструкции мы инкапсулируем графическую память и код в классе
Image
. Кадр размером 64x32 пикселя будет представлен в
виде единого фрагмента данных в памяти. Каждому пикселю будет
соответствовать один байт:
0x000:|--------------------------------------------------------------|0x040:| |0x080:| |0x0C0:| |...0x7C0:|--------------------------------------------------------------|
Для описания этой памяти нам понадобится три значения: количество строк, количество столбцов и начальный адрес (нам его даёт malloc). Если у нас есть этот адрес, указывающий на элемент графической памяти, находящийся в верхнем левом углу вышеприведённой схемы, обращение к отдельным пикселям будет выполняться очень просто. Вот несколько примеров:
img[col=0, row=0] = img[0]img[col=0, row=1] = img[width]img[col=1, row=3] = img[3*width+1]
Теперь, когда у нас есть эти сведения, мы готовы к тому, чтобы создать соответствующий заголовочный файл:
// image.hclass Image {public:// Выделение и освобождение памяти в ctor и dtor.Image(int cols, int rows);~Image();uint8_t* Row(int r);// Возвращает пиксель, который может быть изменён.uint8_t& At(int c, int r);void SetAll(uint8_t value);private:int cols_;int rows_;uint8_t* data_;};
Тут надо обратить внимание на то, что мы динамически выделяем память, владельцем которой будет этот класс. В более крупной системе мы могли бы решить воспользоваться
std::unique_ptr
вместе с особой функцией для выделения
памяти. Но тут мы просто используем malloc
в
конструкторе и free
в деструкторе класса.
// image.cppImage::Image(int cols, int rows) {data_ = static_cast<uint8_t*>(malloc(cols * rows * sizeof(uint8_t)));cols_ = cols;rows_ = rows;}Image::~Image() {free(data_);}uint8_t* Image::Row(int r) {return &data_[r * cols_];}uint8_t& Image::At(int c, int r) {return Row(r)[c];}void Image::SetAll(uint8_t value) {std::memset(data_, value, rows_ * cols_);}void Image::DrawToStdout() {for (int r = 0; r < rows_; r++) {for (int c = 0; c < cols_; c++) {if (At(c,r) > 0) {std::cout << "X";} else {std::cout << " ";}}std::cout << std::endl;}std::cout << std::endl;}
Здесь мне удобнее пользоваться такими именами переменных, как
rows_
(строки) и cols_
(столбцы), а не
x
и y
. Дело в том, что имя переменной
rows чётко ассоциируется у меня со строками, а вот о том, что такое
x, я вполне могу забыть. Функция At
возвращает
uint8_t&
, что даёт нам возможность и получать
значения отдельных пикселей, и устанавливать эти значения. Это
плохо с точки зрения инкапсуляции, но такой приём часто
используется в графических API. Мы, кроме того, предусмотрели тут
удобную функцию DrawToStdout
, которая позволяет
выводить в консоль то, что должно быть отображено на экране, делая
это даже тогда, когда подсистема графического вывода эмулятора ещё
не реализована. Сейчас мы можем добавить в класс
CpuChip8
поле frame_
типа
Image
и поработать над реализацией соответствующих
механизмов.
// cpu_chip8.hclass CpuChip8 {public:constexpr innt kFrameWidth = 64;constexpr innt kFrameHeight = 32;CpuChip8() : frame_(kFrameWidth, kFrameHeight) {}...private:...Image frame_;};
Теперь давайте поговорим о том, как CHIP-8 выполняет вывод графических данных. А именно, рисование спрайта в текущем (и единственном) кадровом буфере выполняется по принципам, используемым в операции
XOR
. Все спрайты описываются в
виде изображений с глубиной цвета в 1 бит (каждый пиксель может
быть либо включен, либо выключен). Ширина спрайта равняется 8
битам, высота может меняться. Ограничение на ширину спрайта
применяется из-за того, что каждый пиксель спрайта представлен
единственным битом. Посмотрим на описание набора шрифтов,
присутствующее в предыдущем материале, и попробуем расшифровать
одну из цифр.
0xF0, 0x90, 0x90, 0x90, 0xF0, // 00xF0 это 1111 0000 -> XXXX0x90 это 1001 0000 -> X X0x90 это 1001 0000 -> X X0x90 это 1001 0000 -> X X0xF0 это 1111 0000 -> XXXX
Замечательно! Помните, я говорил о том, что вывод графики основан на операции XOR? Так вот, это значит, что единственный способ убрать спрайт с экрана заключается в том, чтобы вывести ещё один спрайт поверх него (фактически тот же самый спрайт), так как 1 1 даёт 0. Именно поэтому при работе с CHIP-8-программами часто заметно мерцание, так как спрайты постоянно выводятся на экран и стираются с него для вывода движущихся объектов.
Итак, мы готовы к тому, чтобы создать функцию для вывода спрайтов. Нам понадобится начальная точка и сам спрайт (область памяти). Так как спрайты могут иметь переменную высоту, мы получаем и соответствующий параметр, описывающий её. Отмечу, что одна особенность интерпретатора CHIP-8 потребовала некоторого времени на её отладку. Она заключается в том, что интерпретатор поддерживает вывод графики за пределами экрана. Когда спрайт выходит за границы экрана, его рисование продолжается на другой стороне экрана. Это поведение проявляется и при указании стартовых координат спрайта (то есть вывод 15 строк в координате 255,255 это совершенно нормально). Кроме того, интерпретатору нужно сообщать о том, был ли при выводе спрайта стёрт какой-нибудь пиксель (это часто используется для обнаружения столкновений объектов, выводимых на экран).
// image.cpp// Возвращает true в том случае, если новое значение стирает пиксель.bool Image::XOR(int c, int r, uint8_t val) {uint8_t& current_val = At(c, r);uint8_t prev_val = current_val;current_val ^= val;return current_val == 0 && prev_val > 0;}bool Image::XORSprite(int c, int r, int height, uint8_t* sprite) {// Переход на другую сторону экрана при выводе спрайта.bool pixel_was_disabled = false;for (int y = 0; y < height; y++) {int current_r = r + y;while (current_r >= rows_) { current_r -= rows_; }uint8_t sprite_byte = sprite[y];for (int x = 0; x < 8; x++) {int current_c = c + x;while (current_c >= cols_) { current_c -= cols_; }// Обратите внимание: Сканирование выполняется от MSbit до LSbituint8_t sprite_val = (sprite_byte & (0x80 >> x)) >> (7-x);pixel_was_disabled |= XOR(current_c, current_r, sprite_val);}}return pixel_was_disabled;}
Нам нужно позаботиться о том, чтобы извлекать биты, представляя их значениями
1
или 0
. Так как класс
Image
поддерживает [0..255]
, операции
XOR, без этого ограничения, могут наделать много беспорядка. Когда
же применяется это ограничение, соответствующая инструкция нашего
CPU получается очень простой нужно всего лишь извлечь параметры,
необходимые для вызова XORSprite
.
CpuChip8::Instruction CpuChip8::GenDRAW(uint8_t reg_x, uint8_t reg_y, uint8_t n_rows) {return [this, reg_x, reg_y, n_rows]() {uint8_t x_coord = v_registers_[reg_x];uint8_t y_coord = v_registers_[reg_y];bool pixels_unset = frame_.XORSprite(x_coord, y_coord, n_rows,memory_ + index_register_);v_registers_[0xF] = pixels_unset;NEXT;};}
Итоги
Если вы дошли до этого момента у вас уже должна появиться возможность запускать некоторые ROMы! Поставьте вызов
DrawToStdout
после цикла выполнения кода и
понаблюдайте за тем, что попадает в консоль. Правда, пока на нашем
интерпретаторе можно запускать только программы, не ожидающие
пользовательского ввода.В следующем материале из этой серии мы подключим к проекту библиотеку SDL, что позволит выводить графику на экран.
Если бы вы писали собственный интерпретатор CHIP-8 каким языком программирования вы бы пользовались?