Эмуляция компьютера: интерпретатор CHIP-8, графика и стриминг текстур

от автора

В прошлый раз мы остановились на том, что создали интерпретатор 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, которое получает актуальные сведения о текстурах и выполняет рендеринг. Конструктор принимает показатель масштабирования окна, так как если попытаться вывести на экран изображение размером 64×32 пикселя без масштабирования, оно окажется очень маленьким.

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 столь популярен?

ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/535762/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *