Решать вышеозначенные задачи мы будем с помощью библиотеки 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.cppvoid 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.cppvoid 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 столь популярен?