В прошлый раз мы остановились на том, что создали интерпретатор 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 столь популярен?