Перевернутый мир

Для разработки нового проекта Cube Art Project взял на вооружение методологию разработки Test Driven Development. В данном подходе сначала реализуется тест для определенного функционала приложения, а затем уже реализуется конкретный функционал. Большим плюсом в данном подходе я считаю реализацию финальных интерфейсов, максимально непосвященных в детали реализации, до начала разработки функционала. При таком подходе тест диктует дальнейшую реализацию, добавляются все преимущества контрактного программирования, когда интерфейсы являются контрактами для конкретной реализации.
Cube Art Project – 3D редактор в котором пользователь строит фигуры из кубов, не так давно этот жанр был очень популярен. Так как это графическое приложение, то я решил добавить тесты с валидацией скриншотов.
Для валидирования скриншотов нужно получить их из OpenGL контекста, делается это с помощью функции glReadPixels. Описание аргументов функции простейшие – начальная позиция, ширина, высота, формат (RGB/RGBA/проч.), указатель на выходной буфер, любому работавшему с SDL или имеющему опыт с буферами данных в Си будет просто подставить нужные аргументы. Однако считаю необходимым описать интересную особенность выходного буфера glReadPixels, пиксели в нем хранятся снизу вверх, а в SDL_Surface все базовые операции происходят сверху вниз.
То есть загрузив референсный скриншот из png файла, я не смог сравнить два буфера в лоб, так как один из них был перевернутым.
Чтобы перевернуть выходной буфер из OpenGL вам нужно заполнить его отнимая высоту скриншота для координаты Y. Однако стоить учесть что есть шансы выйти за пределы буфера, если не отнять единицу во время заполнения, что приведет к memory corruption.
Так как я повсеместно стараюсь использовать ООП парадигму “программирования интерфейсами”, вместо прямого Си-подобного доступа к памяти по указателю, то при попытке записать данные за пределами буфера объект мне об этом сообщил благодаря валидации границ в методе.
Итоговый код метода получения скриншота в стиле сверху-вниз:


    auto width = params->width;
    auto height = params->height;

    auto colorComponentsCount = 3;
    GLubyte *bytes = (GLubyte *)malloc(colorComponentsCount * width * height);
    glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, bytes);

    auto screenshot = make_shared(width, height);

    for (auto y = 0; y < height; y++) {
        for (auto x = 0; x < width; x++) {
            auto byteX = x * colorComponentsCount;
            auto byteIndex = byteX + (y * (width * colorComponentsCount));
            auto redColorByte = bytes[byteIndex];
            auto greenColorByte = bytes[byteIndex + 1];
            auto blueColorByte = bytes[byteIndex + 2];
            auto color = make_shared(redColorByte, greenColorByte, blueColorByte, 255);
            screenshot->setColorAtXY(color, x, height - y - 1);
        }
    }

    free(bytes);

Источники

https://community.khronos.org/t/glreadpixels-fliped-image/26561
https://stackoverflow.com/questions/8346115/why-are-bmps-stored-upside-down

Исходный код

https://gitlab.com/demensdeum/cube-art-project-bootstrap

WebGL + SDL + Emscripten

В итоге я портировал Мику на WebGL, с помощью SDL 1 и Emscripten.

Дальше я опишу что нужно было изменить в коде чтобы сборка в JavaScript завершилась успешно.

  1. Использовать SDL 1 вместо SDL 2. На данный момент существует порт SDL 2 для emscripten, однако использовать встроенный в emscripten SDL 1 я посчитал более целесообразным. Инициализация контекста происходит не в окне, а с помощью SDL_SetVideoMode и флага SDL_OPENGL. Отрисовка буфера производится командой SDL_GL_SwapBuffers()
  2. Из-за особенностей выполения циклов в JavaScript – рендеринг вынесен в отдельную функцию и его периодический вызов проставляется с помощью функции emscripten_set_main_loop
  3. Также сборку нужно осуществлять с ключом “-s FULL_ES2=1
  4. Пришлось отказаться от библиотеки assimp, от загрузки модели из файловой системы, от загрузки текстуры с диска. Все необходимые буферы были прогружены на деcктоп версии, и прокинуты в c-header файл для сборки с помощью emscripten.

Код:
https://github.com/demensdeum/OpenGLES3-Experiments/tree/master/9-sdl-gles-obj-textured-assimp-miku-webgl/mikuWebGL

Статьи:
http://blog.scottlogic.com/2014/03/12/native-code-emscripten-webgl-simmer-gently.html
https://kripken.github.io/emscripten-site/docs/porting/multimedia_and_graphics/OpenGL-support.html

Модель:
https://sketchfab.com/models/7310aaeb8370428e966bdcff414273e7

Есть только Мику

Результат работы над библиотекой FSGL с OpenGL ES и код:

Дальше я опишу как это все программировалось, решались разные интересные проблемы.

Сначала мы проинициализируем OpenGL ES контекст, как это делается я писал в прошлой заметке. Дальше будет рассматриваться только отрисовка, краткое описание кода.

Матрица следит за тобой

Данная фигура Мику на видео состоит из треугольников. Чтобы нарисовать треугольник в OpenGL, нужно задать три точки к координатами x, y, z. в 2D координатах контекста OpenGL.
Так как нам нужно отрисовать фигуру содержащую 3D координаты, нам нужно использовать матрицу проекции (projection). Также нам нужно крутить, увеличивать, или что угодно делать с моделью – для этого используется матрица модели (model). Понятия камеры в OpenGL нет, на самом деле объекты крутятся, поворачиваются вокруг статичной камеры. Для этого используется матрица вида (view).

Для упрощения реализации OpenGL ES – в нем данные матрицы отсутствуют. Вы можете использовать библиотеки которые добавляют отсутствующий функционал, например GLM.

Шейдеры

Для того чтобы позволить разработчику рисовать что угодно, и как угодно, в OpenGL ES нужно обязательно реализовать вертексные и фрагментные шейдеры. Вертексный шейдер должен получить на вход координаты отрисовки, произвести преобразования с помощью матриц, и передать координаты в gl_Position. Фрагментный или пиксельный шейдер – уже отрисовывает цвет/текстуру, применяет наложение и пр.

Шейдеры я писал на языке GLSL. В моей текущей реализации шейдеры встроены прямо в основной код приложения как C-строки.

Буферы

Вертексный буфер содержит координаты вершин (вертексов), в данный буфер также попадают координаты для текстурирования и прочие необходимые для шейдеров данные. После генерации вертексного буфера, нужно забиндить указатель на данные для вертексного шейдера. Это делается командой glVertexAttribPointer, там необходимо указать количество элементов, указатель на начало данных и размер шага, который будет использоваться для прохода по буферу. В моей реализации сделан биндинг координат вершин и текстурные координаты для пиксельного шейдера. Однако стоит сказать что передача данных (текстурных координат) во фрагментный шейдер осуществляется через вертексный шейдер. Для этого координаты объявлены с помощью varying.

Для того чтобы OpenGL знал в каком порядке отрисовывать точки для треугольников – вам понадобится индексный буфер (index). Индексный буфер содержит номер вертекса в массиве, с помощью трех таких индексов получается треугольник.

Текстуры

Для начала нужно прогрузить/сгенерировать текстуру для OpenGL. Для этого я использовал SDL_LoadBMP, загрузка текстуры происходит из bmp файла. Однако стоит отметить что годятся только 24-битные BMP, также цвета в них хранятся не в привычном порядке RGB, а в BGR. Тоесть после прогрузки нужно осуществить замену красного канала на синий.
Текстурные координаты задаются в формате UV, тоесть необходимо передать всего две координаты. Вывод текстуры осуществляется во фрагментном шейдере. Для этого необходимо осуществить биндинг текстуры во фрагментный шейдер.

Ничего лишнего

Так как, по нашему указанию, OpenGL рисует 3D через 2D – то для реализации глубины, и выборки невидимых треугольников – нужно использовать выборку (culling) и буфер глубины (Z-Buffer). В моей реализации удалось избежать ручной генерации буфера глубины, с помощью двух команд glEnable(GL_DEPTH_TEST); и выборки glEnable(GL_CULL_FACE);
Также обязательно проверьте что near plane для матрицы проекции больше нуля, т.к. проверка глубины с нулевым near plane работать не будет.

Рендеринг

Чтобы заполнить вертексный буфер, индексный буфер чем-то осознанным, например моделью Мику, нужно осуществить загрузку данной модели. Для этого я использовал библиотеку assimp. Мику была помещена в файл формата Wavefront OBJ, прогружена с помощью assimp, и реализована конвертация данных из assimp в вертексный, индексный буферы.

Рендеринг проходит в несколько этапов:

  1. Поворот Мику с помощью поворота матрицы модели
  2. Очистка экрана и буфера глубины
  3. Отрисовка треугольников с помощью команды glDrawElements.

Следующий этап – реализация рендеринга в WebGL с помощью Emscripten.

Исходный код:
https://github.com/demensdeum/OpenGLES3-Experiments/tree/master/8-sdl-gles-obj-textured-assimp-miku
Модель:
https://sketchfab.com/models/7310aaeb8370428e966bdcff414273e7

 

Проецируй это

Нарисовав красный чайник в 3D, я считаю своим долгом кратко описать как это делается.

Современный OpenGL не рисует 3D, он рисует только треугольники, точки, и пр. в 2D координатах экрана.
Чтобы вывести с помощью OpenGL хоть что-то, нужно предоставить вертексный буфер, написать вертексный шейдер, добавить в вертексный шейдер все необходимые матрицы (проекция, модель, вью), связать все входные данные с шейдером, вызвать метод отрисовки в OpenGL. Выглядит просто?


Ок, что такое вертексный буфер? Список координат которые необходимо отрисовать (x, y, z)
Вертексный шейдер говорит GPU какие координаты нужно рисовать.
Пиксельный шейдер говорит что рисовать (цвет, текстуру, блендинг и тд.)
Матрицы транслируют 3D координаты в 2D координаты OpenGL которые он может отрисовать

В следующих статьях я приведу примеры кода и результат.

SDL2 – OpenGL ES

Я люблю Panda3D. Однако этот игровой движок слишком сложен в компиляции/поддержке для платформы Microsoft Windows. Поэтому я решил заняться разработкой собственной графической библиотеки на OpenGL ES и SDL2.
В этой статье я опишу как инициализировать OpenGL контекст. Мы выведем пустое окно.

King Nothing

Для начала установим библиотеки OpenGL ES3 – GLES 3. На убунте это делается легко, командой sudo apt-get install libgles2-mesa-dev. Для работы с OpenGL, необходимо проинициализировать контекст. Для решения данной задачи есть много вспомогательных библиотек – SDL2, GLFW, GLFM и тд. На самом деле, единственного варианта инициализации для всех платформ не существует, я выбрал SDL2 т.к. код будет един для Windows/*nix/HTML5/iOS/Android/и тд.

Установить SDL2 на убунте можно командой sudo apt-get install libsdl2-dev

Код для инициализации контекста OpenGL с помощью SDL2:

    SDL_Window *window = SDL_CreateWindow(
            "SDL2 - OGLES",
            SDL_WINDOWPOS_UNDEFINED,
            SDL_WINDOWPOS_UNDEFINED,
            640,
            480,
            SDL_WINDOW_OPENGL
            );
	    

    SDL_GLContext glContext = SDL_GL_CreateContext(window);

После этого можно делать вызовы OpenGL, которые будут отрабатывать в данном контексте.

Пример с выводом окна на OpenGL ES с синей заливкой:
https://github.com/demensdeum/OpenGLES3-Experiments/tree/master/3sdl-gles
https://github.com/demensdeum/OpenGLES3-Experiments/blob/master/3sdl-gles/sdlgles.cpp

Собрать и проверить можно с помощью команды cmake . && make && ./SDLGles