В процессе освоения OpenGL-функций в Rust родился побочный квест.
У меня есть старый проект Генетической Моны Лизы, который я тут неоднократно описывал. Вот последняя публикация со ссылками на предыдущие:
Рисование там делается с помощью цветных треугольников, которые растеризуются вручную. Но массив цветных треугольников просто создан для рендеринга в OpenGL, и это бы существенно ускорило работу алгоритма.
Я, однако, медлил с доработками, пока случайно не наткнулся на более примитивный вариант рисования. Он не требует создания вершинного и фрагментного шейдера и в целом проще.
Это одна из ранних версий OpenGL, где шейдеров ещё не было, а графический конвейер был фиксированный.
Но как раз это в данном случае и нужно. Так что я приступил к переделке кода.
SDL2 + GL
В основе остаётся библиотека SDL2, к которой добавляется библиотека OpenGL. Вызовы GL частично обёрнуты в методы SDL2.
Окно нужно создать с флагом SDL_WINDOW_OPENGL:
Затем установим атрибуты OpenGL и получим контекст:
Это мажорная и минорная версия (2.1), что говорит о её древности. Далее, стандартная двойная буферизация и глубина прозрачности 8 бит.
Настроим параметры отображения:
Каждая строчка здесь важна.
glViewport()
В OpenGL используются нормализованные координаты устройства (NDC), которые меняются от -1 до 1. Они нормализованы в том смысле, что не зависят от точных размеров окна: для оси X -1 это всегда левая граница, 1 это всегда правая. Для оси Y -1 это всегда нижняя граница, а 1 всегда верхняя. Эти границы мы привязываем к конкретным координатам окна, например -1 по оси X будет соответствовать 0, а 1 будет соответствовать screenWidth.
Таким образом мы растягиваем сцену OpenGL на всё окно:
Но если я привяжу координаты NDC не к целому окну, а к его части, например, только до половины ширины и высоты:
glViewport(0, 0, screenWidth / 2, screenHeight / 2);
то отображение будет заполнять только эту часть:
А если задать высоту вдвое меньше ширины, изображение соответственно сплющится:
Таким образом, сцена, заданная в нормализованных координатах, всегда будет автоматически растягиваться для любого конкретного размера окна.
glOrtho()
Так как мы работаем с 3D-графикой, которая отображается на 2D-экран, нужно строить проекцию: как именно из 3D-координат получаются 2D. В нашем случае координата Z ни на что не влияет, поэтому мы задаём ортогональную проекцию с помощью метода glOrtho(). Это куб (точнее, кубоид), стенки которого (x: слева-справа, y: сверху-снизу, z: близко-далеко) служат границами отображения. Вся графика, которая выходит за стенки, не отображается.
Соответственно, для границ данного кубоида я тоже указываю полные размеры окна. Но если его сделать меньше, то графика обрежется:
И одновременно случится "зум", потому что границы кубоида будут приведены к нормализованным -1 и 1. А если сделать его больше, то наоборот появятся пустые поля:
Оставшиеся методы
- glEnable(GL_BLEND) включает работу с прозрачностью
- glDisable(GL_DEPTH_TEST) выключает буфер глубины; треугольники будут рисоваться в том порядке, в котором заданы, перекрывая друг друга
- glBlendFunc() задаёт стандартную функцию смешивания цветов
Получение указателей на функции
Теперь нужно получить указатели на те функции OpenGL, которые будут использоваться. Их нельзя определить заранее, так как они находятся где-то в драйвере, поэтому требуется делать запросы на получение указателей уже во время работы программы.
Для этих целей используют специальные библиотеки вроде GLEW, Glad..., но это порождает лишние зависимости в проекте. Как оказалось, можно обойтись своими силами, особенно если не требуется много функций.
Итак, мне нужны следующие функции: glGenBuffers(), glBindBuffer(), glBufferData(), glBufferSubData(), glDeleteBuffers().
Я создаю статические переменные типа "указатель на функцию" (эти типы уже определены в файле SDL2_opengl.h):
Далее получаю указатели с помощью SDL_GL_GetProcAddress() и сохраняю в эти переменные:
Вот и всё, никаких зависимостей добавлять не надо.
Подготовка буфера
В контексте генетического алгоритма набор треугольников это хромосома, где каждый треугольник это ген. Я ограничиваю количество генов в хромосоме константой MAX_CHROMO_SIZE.
Каждый треугольник содержит три вершины. Структура Vertex описывает вершину с координатами и цветом, и будет использована для OpenGL без изменений:
Соответственно, буфер вершин для OpenGL будет иметь максимальный размер MAX_CHROMO_SIZE * 3, так как каждый треугольник это три вершины:
static Vertex vertex_buffer[MAX_CHROMO_SIZE * 3];
Это буфер на нашей стороне. Аналогичный буфер надо создать на стороне графической карты:
- glGenBuffers() создаёт на стороне видеокарты 1 буфер и помещает его "ярлычок" в переменную vbo, так как прямого указателя на него быть не может
- glBindBuffer() делает указанный буфер текущим для определённой цели, в данном случае цель это GL_ARRAY_BUFFER. Это значит, что он будет использоваться для задания вершин.
- Наконец, glBufferData() резервирует память в этом буфере такого же размера, как и наш vertex_buffer. NULL означает, что копирования данных сейчас не будет, а GL_DYNAMIC_DRAW это флаг, который подсказывает, что данные в буфере будут часто меняться, и что он используется для рисования.
Продолжаем подготовку...
Для начала рисования требуется ещё немного:
- glEnableClientState(GL_VERTEX_ARRAY) означает, что буфер содержит информацию о координатах вершин
- glEnableClientState(GL_COLOR_ARRAY) означает, что буфер содержит информацию о цветах вершин
Далее нужно уточнить, где именно в буфере расположена эта информация и в каком она формате:
- glVertexPointer() задаёт атрибуты координат вершин: каждая вершина это 2 координаты, каждая координата это 16-битное целое (GL_SHORT), каждая вершина в целом имеет размер структуры Vertex, а координаты в этой структуре начинаются со смещения поля x (иначе говоря, это 0).
- glColorPointer() задаёт атрибуты цветов вершин: каждый цвет это 4 компонента (RGBA), каждый компонент это 1 байт (GL_UNSIGNED_BYTE), каждая вершина в целом имеет размер структуры Vertex, а цвет в этой структуре начинается со смещения поля pixel (иначе говоря, это 4).
Напомню, что это устаревшие функции для фиксированного конвейера без шейдеров.
Рисование
К сожалению, мне приходится копировать данные вершин из хромосомы в vertex_buffer, хотя можно было организовать хранение так, чтобы этого не делать. Но в качестве прототипа сойдёт.
После копирования, имея готовый к рисованию vertex_buffer, я вызываю:
glBufferSubData() позволяет перекинуть данные из vertex_buffer "на ту сторону". При этом мы указываем, какую именно часть буфера копировать: с индекса 0 и длиной chromo->count * 3 * sizeof(Vertex), то есть ровно актуальное количество треугольников.
И, наконец glDrawArrays() всё рисует. Туда передаётся флаг GL_TRIANGLES, который поясняет, что нужно рисовать не точки, не линии, а треугольники, и также стартовый индекс и количество элементов буфера. То есть можно отрисовывать не весь имеющийся буфер, а произвольную его часть, что мне здесь не нужно.
После рисования нужно посчитать рейтинг хромосомы, для чего нужно получить содержимое экрана:
Данные из памяти видеокарты копируются в поверхность (Surface), в которую я могу уже лазить как обычно.
Плюсы, минусы, подводные камни
Главный плюс – высокая скорость рисования. Программа стала работать раз в 10 быстрее.
На иллюстрации слева обычный вариант, а справа OpenGL:
OpenGL был запущен на 3-4 часа позже, но успел догнать и перегнать обычный вариант.
Интересно, что в системе координат OpenGL координата Y увеличивается вверх, а в системе экрана вниз. Я задаю координаты треугольников в системе экрана, а в OpenGL картинка соответственно рисуется вниз головой, да ещё и с неправильным порядком RGB.
Если вывести на экран то, что нарисовал OpenGL, мы увидим перевёрнутую синюшную картинку.
Но при чтении экранного содержимого картинка переворачивается обратно и порядок RGB тоже становится правильным. Поэтому проблем тут нет. Треугольники всё равно будут мутировать в нужную сторону.
Что более интересно, это адресация пикселов.
В экранном пространстве пикселы имеют какой-то физический размер, но неделимы. Если мы говорим "закрасить пиксел такой-то", то он всегда закрасится целиком. Для этого нам нужно знать позицию пиксела. Если ширина окна 800 пикселов, то самый левый пиксел будет иметь позицию 0, а самый правый – 799.
Однако в OpenGL мы не можем закрасить один пиксел. Мы можем сказать: "закрасить с этой по эту координату". Тогда смотрите что получается. Я сделаю картинку из четырёх пикселов для наглядности:
Тут четыре пиксела с индексами 0, 1, 2, 3, и каждый пиксел занимает некоторое пространство: пиксел 0 находится между координатами 0..1, пиксел 1 находится между 1..2, пиксел 2 между 2..3, и пиксел 3 между 3..4.
Если мы скажем "закрасить с 0 по 3", получится так:
Чтобы закрасить все пикселы, нужно указать координаты с 0 по 4:
Поэтому генерацию треугольников мне пришлось изменить так, чтобы, например, для ширины окна 800 пикселов координаты вершин находились не в пределах 0..799, а 0..800.
Но для рисования служебной информации, например последнего изменённого треугольника:
мне требовались уже обычные пикселы, так что пришлось вводить дополнительные проверки, чтобы позиции этих пикселов не выходили за края экрана.
Сделал для этого проекта отдельный репозиторий на гитхабе: