Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
В прошлый раз мы остановились на том, что создали интерпретатор CHIP-8 и оснастили его системой для формирования кадров. Видеть то, что должно попасть на экран, можно в консоли. Теперь же мы собираемся взять то, что формирует интерпретатор, вынести это за пределы консоли и показать на экране.
Решать вышеозначенные задачи мы будем с помощью библиотеки SDL, которая умеет выводить графические данные на экран, принимать то, что вводит пользователь, и проигрывать звуки. Настройка SDL-проекта может вызвать некоторые сложности. Поэтому я рекомендую перед началом работы с библиотекой почитать мой материал о ней.
Есть много способов вывести что-либо на экран с использованием SDL. В играх, в основном, изображения не формируются, как в нашем случае, средствами CPU. Но при эмуляции и (что встречается чаще) при воспроизведении видео изображение (вполне возможно — сжатое) готовится к выводу средствами CPU. Такое изображение, для вывода его на экране, нужно загрузить в GPU. После того, как изображение попадёт в GPU, мы называем его «текстурой», а весь этот процесс называют «стримингом текстур».
Изображение, формируемое средствами класса
Мы планируем использовать этот класс как SDL-окно RAII, которое получает актуальные сведения о текстурах и выполняет рендеринг. Конструктор принимает показатель масштабирования окна, так как если попытаться вывести на экран изображение размером 64x32 пикселя без масштабирования, оно окажется очень маленьким.
Тут нужно выполнить некоторые стандартные процедуры по инициализации SDL-механизмов в конструкторе класса и по освобождению ресурсов в деструкторе. Метод
Загрузка текстуры в GPU выполняется в
Теперь нам надо отредактировать код главного цикла, сделав так, чтобы в нём использовалось бы новое окно.
Мы инициализируем RGB24-картинку пустым изображением (нулями, чёрным цветом). Обратите внимание на то, что размер этого изображения вычисляется не как
При создании RGB-изображений данные красного (red), зелёного (green) и синего (blue) цветовых каналов каждого пикселя часто идут в памяти друг за другом. Поэтому простое добавление 1 к адресу памяти уже необязательно позволит нам получить значение, соответствующее следующему пикселю.
Нам, прежде чем мы сможем это обсудить, понадобится ввести некоторые новые термины. То, что называется «stride» или «pitch», представляет собой ширину строки изображения в байтах. В данном случае это —
Тут мы перебираем данные исходного изображения и создаём его эквивалент в
Теперь на экране можно наблюдать за изображением, формируемым эмулятором. Мы даже пользуемся тут возможностями GPU! То, что выводит ваш вариант эмулятора, должно сильно напоминать, например, то, что показано в этом видеофрагменте. В следующий раз поговорим о временных параметрах работы эмулятора, о том, как заставить систему работать с нужной скоростью, и о том, как обрабатывать ввод данных пользователем.
Как вы думаете, почему эмулятор 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 столь популярен?