Подготовка к выводу изображений
В прошлый раз мы написали интерпретатор CHIP-8, который способен выполнять все операции за исключением одной — Dxyn (DRW Vx, Vy, nibble). Ради упрощения реализации этой инструкции мы инкапсулируем графическую память и код в классе Image. Кадр размером 64×32 пикселя будет представлен в виде единого фрагмента данных в памяти. Каждому пикселю будет соответствовать один байт:
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.h class 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.cpp Image::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.h class 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, // 0 0xF0 это 1111 0000 -> XXXX 0x90 это 1001 0000 -> X X 0x90 это 1001 0000 -> X X 0x90 это 1001 0000 -> X X 0xF0 это 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 до LSbit uint8_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 — каким языком программирования вы бы пользовались?
ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/535760/


Добавить комментарий