В прошлый раз мы остановились на том, что создали интерпретатор CHIP-8 и оснастили его системой для формирования кадров. Видеть то, что должно попасть на экран, можно в консоли. Теперь же мы собираемся взять то, что формирует интерпретатор, вынести это за пределы консоли и показать на экране.
Решать вышеозначенные задачи мы будем с помощью библиотеки SDL, которая умеет выводить графические данные на экран, принимать то, что вводит пользователь, и проигрывать звуки. Настройка SDL-проекта может вызвать некоторые сложности. Поэтому я рекомендую перед началом работы с библиотекой почитать мой материал о ней.
Есть много способов вывести что-либо на экран с использованием SDL. В играх, в основном, изображения не формируются, как в нашем случае, средствами CPU. Но при эмуляции и (что встречается чаще) при воспроизведении видео изображение (вполне возможно — сжатое) готовится к выводу средствами CPU. Такое изображение, для вывода его на экране, нужно загрузить в GPU. После того, как изображение попадёт в GPU, мы называем его «текстурой», а весь этот процесс называют «стримингом текстур».
Изображение, формируемое средствами класса Image
, представлено в некоем графическом формате. Но SDL «понимает» лишь определённый набор пиксельных форматов. Если взглянуть на эти форматы, то окажется, что нам вполне может подойти SDL_PIXELFORMAT_RGB24
. Настроим класс SDLViewer
, который будет стримить изображения в этом формате, а чуть позже поразмыслим о том, как преобразовать данные нашего кадрового буфера в RGB24
.
// sdl_viewer.h
// SDL-окно RAII с поддержкой аппаратного ускорения.
// Оптимизировано для стриминга RGB24-текстур.
class SDLViewer {
public:
// Ширина и высота должны быть равны параметрам изображения, загружаемого
// через SetFrameRGB24.
SDLViewer(const std::string& title, int width, int height, int window_scale = 1);
~SDLViewer();
// Рендеринг текущего кадра, возврат списка событий.
std::vector<SDL_Event> Update();
// Предполагается, что это - 8-битное RGB-изображение, ширина которого в байтах равна его ширине в пикселях (без необходимости использовать заполнители).
void SetFrameRGB24(uint8_t* rgb24, int height);
private:
SDL_Window* window_ = nullptr;
SDL_Renderer* renderer_ = nullptr;
SDL_Texture* window_tex_ = nullptr;
};
Мы планируем использовать этот класс как SDL-окно RAII, которое получает актуальные сведения о текстурах и выполняет рендеринг. Конструктор принимает показатель масштабирования окна, так как если попытаться вывести на экран изображение размером 64x32 пикселя без масштабирования, оно окажется очень маленьким.
SDLViewer::SDLViewer(const std::string& title, int width, int height, int window_scale) :
title_(title) {
if(SDL_Init(SDL_INIT_VIDEO) < 0) {
throw std::runtime_error(SDL_GetError());
}
// Создание SDL-окна с учётом коэффициента масштабирования.
window_ = SDL_CreateWindow(title.c_str(), SDL_WINDOWPOS_UNDEFINED,
SDL_WINDOWPOS_UNDEFINED, width * window_scale, height * window_scale, SDL_WINDOW_SHOWN);
// Настройка аппаратной системы рендеринга и текстуры, которую мы будем стримить.
renderer_ = SDL_CreateRenderer(window_, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
SDL_SetRenderDrawColor(renderer_, 0xFF, 0xFF, 0xFF, 0xFF);
window_tex_ = SDL_CreateTexture(renderer_, SDL_PIXELFORMAT_RGB24,
SDL_TEXTUREACCESS_STREAMING, width, height);
}
SDLViewer::~SDLViewer() {
SDL_DestroyTexture(window_tex_);
SDL_DestroyRenderer(renderer_);
SDL_DestroyWindow(window_);
SDL_Quit();
}
std::vector<SDL_Event> SDLViewer::Update() {
std::vector<SDL_Event> events;
SDL_Event e;
while (SDL_PollEvent(&e)) { events.push_back(e); }
// Рендеринг текстуры.
SDL_RenderCopy(renderer_, window_tex_, NULL, NULL );
SDL_RenderPresent(renderer_);
return events;
}
void SDLViewer::SetFrameRGB24(uint8_t* rgb24, int height) {
void* pixeldata;
int pitch;
// Блокировка текстуры и загрузка изображения в GPU.
SDL_LockTexture(window_tex_, nullptr, &pixeldata, &pitch);
std::memcpy(pixeldata, rgb24, pitch * height);
SDL_UnlockTexture(window_tex_);
}
Тут нужно выполнить некоторые стандартные процедуры по инициализации SDL-механизмов в конструкторе класса и по освобождению ресурсов в деструкторе. Метод Update
будет представлять свежее изображение, отправленное SDLViewer
. Он, кроме того, отвечает за приём событий, связанных с вводом данных.
Загрузка текстуры в GPU выполняется в SetFrameRGB24
. Функция принимает сведения о фрагменте памяти, в котором хранится изображение в нужном формате, а так же сведения о высоте изображения. SDL_LockTexture
возвращает CPU-память для копирования графических данных. Ещё эта функция возвращает длину строки изображения в байтах. После того, как изображение скопировано в выделенный участок памяти, мы вызываем функцию SDL_UnlockTexture
, которая выгружает изображение в GPU в виде новой текстуры.
Теперь нам надо отредактировать код главного цикла, сделав так, чтобы в нём использовалось бы новое окно.
// main.cpp
void Run() {
int width = 64;
int height = 32;
SDLViewer viewer("CHIP-8 Emulator", width, height, /*window_scale=*/8);
uint8_t* rgb24 = static_cast<uint8_t*>(std::calloc(
width * height * 3, sizeof(uint8_t)));
viewer.SetFrameRGB24(rgb24, height);
CpuChip8 cpu;
cpu.Initialize("/path/to/program/file");
bool quit = false;
while (!quit) {
cpu.RunCycle();
cpu.GetFrame()->CopyToRGB24(rgb24, /*r=*/255, /*g=*/0, /*b=*/0);
viewer.SetFrameRGB24(rgb24, height);
auto events = viewer.Update();
for (const auto& e : events) {
if (e.type == SDL_QUIT) {
quit = true;
}
}
}
}
Мы инициализируем RGB24-картинку пустым изображением (нулями, чёрным цветом). Обратите внимание на то, что размер этого изображения вычисляется не как width * height
(ширина * высота), а как width * height * 3
(ширина * высота * 3). Мы ведь работаем с RGB-изображением, имеющим 3 цветовых канала. Загрузка текстуры и вывод её на экран выполняются в каждом цикле. Из-за использования vsync оказывается, что эмулятор работает очень медленно. Но мы это исправим, добравшись до настройки временных параметров работы эмулятора. Теперь нам осталось лишь разобраться в том, что собой представляет графический формат RGB24
, и реализовать Image::CopyToRGB24
.
При создании RGB-изображений данные красного (red), зелёного (green) и синего (blue) цветовых каналов каждого пикселя часто идут в памяти друг за другом. Поэтому простое добавление 1 к адресу памяти уже необязательно позволит нам получить значение, соответствующее следующему пикселю.
0x000 :|RGBRGBRGB...----------------------------------------|
0x040*3:|RGBRGBRGB... |
0x080*3:|RGBRGBRGB... |
..
0x7C0*3:|RGBRGBRGB...----------------------------------------|
Нам, прежде чем мы сможем это обсудить, понадобится ввести некоторые новые термины. То, что называется «stride» или «pitch», представляет собой ширину строки изображения в байтах. В данном случае это — 3 * width_px
(3 * ширина в пикселях). Мы можем говорить о байтовой ширине строки изображения и в смысле её отношения к цветовым каналам. Для того чтобы перейти от одного значения красного цвета (канала) в некоем пикселе к такому же значению для следующего пикселя, мы должны прибавить к адресу этого первого значения 3 (это называется «0-dimension stride»). То же самое справедливо и для синего, и для зелёного каналов. При этом каждое отдельное значение, как и прежде, представлено 8 битами (значение может находиться в диапазоне от 0 до 255), но для описания каждого пикселя теперь нужно 3 значения (число «24» в названии «RGB24», в результате, означает результат умножения 3 каналов на 8 битов). Собственно говоря, теперь у нас, похоже, есть всё необходимое для того чтобы сгенерировать изображение нужного формата на основе нашего монохромного изображения.
// image.cpp
void Image::CopyToRGB24(uint8_t* dst, int red_scale, int green_scale, int blue_scale) {
int cols = Cols();
for (int row = 0; row < Rows(); row++) {
for (int col = 0; col < cols; col++) {
dst[(row * cols + col) * 3] = At(col, row) * red_scale;
dst[(row * cols + col) * 3 + 1] = At(col, row) * green_scale;
dst[(row * cols + col) * 3 + 2] = At(col, row) * blue_scale;
}
}
}
Тут мы перебираем данные исходного изображения и создаём его эквивалент в dst
. Каждый пиксель исходного изображения представляем в виде трёх байт нового изображения. Каждый из этих байтов соответствует одному из цветовых каналов. Здесь мы пользуемся знанием того, что пиксели исходного изображения могут пребывать либо в состоянии «выключено», либо в состоянии «включено», применяя коэффициенты при задании значений цветовых каналов и, таким образом, получая готовое изображение, окрашенное в какой-то цвет.
Итоги
Теперь на экране можно наблюдать за изображением, формируемым эмулятором. Мы даже пользуемся тут возможностями GPU! То, что выводит ваш вариант эмулятора, должно сильно напоминать, например, то, что показано в этом видеофрагменте. В следующий раз поговорим о временных параметрах работы эмулятора, о том, как заставить систему работать с нужной скоростью, и о том, как обрабатывать ввод данных пользователем.
Как вы думаете, почему эмулятор CHIP-8 столь популярен?
Автор: programmerguru