Вступление
Это четвертая статья из цикла статей о создании компактной кросс‑платформенной библиотеки для разработки приложений с графическим интерфейсом на языке C++ — Frenchie. Для тех, кто привык изучать исходный код самостоятельно, репозиторий с исходным кодом библиотеки можно найти по ссылке. В данном материале я начну рассказывать про ту часть библиотеки, которая отвечает за rendering - rendering backend.
Цель всего цикла материалов — дать представление о том, как устроены подобные проекты, начиная от открытия контекстного окна и управления его состоянием, заканчивая рисованием графических примитивов. Надеюсь, что весь цикл материалов окажется полезным как для тех, кто начинает свой путь в разработке приложений с графическим интерфейсом, так и для опытных разработчиков.
Весь список материалов представлен ниже:
Как будем делать rendering backend?
Современный rendering довольно сложная и многогранная тема, начинающаяся с аналитической геометрии и заканчивающаяся моделями видеокарты, заложенных в конкретных графических API. Не смотря на все различия в том, каким образом конкретные графические API работают, общий принцип работы с 2D/3D моделями остается +/- одинаковым. Поэтому, в этом материале я постараюсь дать общее представление о том, как в принципе работает rendering, а уже в следующих материалах мы поговорим о конкретных реализациях rendering backend-а с использованием конкретных графических API.
Как в принципе работает rendering?
В данном разделе я постараюсь максимально просто и понятно дать общее представление о том, как работает 2D/3D rendering без привязки к конкретному графическому API. По этой причине, многие вещи могут быть описаны технически неточно, но, я надеюсь, что это не помешает понять общую суть.
Из чего состоит 2D/3D модель?
В компьютерной графике любой 2D/3D объект представляют в виде набора точек, которые соединяются между собой линиями так, чтобы образовалась сетка с непересекающимися клетками. В качестве клеток сетки используют треугольники, т.к абсолютно любую поверхность можно разбить на сетку из треугольников, плюс, все точки треугольника всегда лежат в одной плоскости, что позволяет сильно упростить расчеты цветов моделируемой поверхности, а также унифицировать формат, в котором геометрия обрабатывается на видеокартах. Точки, из которых состоит 2D/3D объект, принято называть вершинами, а набор "вершины + треугольники" называется mesh-ем:

Каждая вершина mesh-а в общей сложности должна иметь координату в пространстве, а также цвет. В принципе, этого могло бы быть достаточно, но нам нужно еще уметь рисовать текстуры. Тексутры обычно натягиваются на треугольники mesh-а при помощи текстурных 2D координат, называемых UV координатами. UV координаты нормируются в диапазоне от 0 до +1 и показывают, какую часть текстуры должен на себя натянуть конкретный треугольник mesh-а:

Как 2D/3D модель хранится в памяти видеокарты?
Итак, с тем, из чего состоят вершины mesh-а разобрались, теперь посмотрим, как эти вершины хранятся в памяти видеокарты.
Координата вершины в пространстве, цвет, UV координата и другие свойства, которые могут быть у вершины называются атрибутами вершины. Для достижения большой производительности атрибуты всех вершин в памяти видеокарты лежат в одном массиве в порядке следования самих вершин mesh-а. Т.е. если у нас есть три вершины, то в массиве с атрибутами вершин идут сначала координата в пространстве, цвет и UV координата первой вершины, потом тоже самое для второй вершины, далее, третьей вершины и так далее:

Чтобы видеокарта знала, как переходить от одной вершины к другой, ей нужно знать сколько места одна вершина занимает в памяти. Количество места, занимаемого одной вершиной в видеопамяти можно посчитать как сумму величин занимаемой памяти каждого отдельного аттрибута вершины. Объем занимаемой вершиной mesh-а памяти называется отступом. Вот этот самый отступ видеокарта и использует, чтобы шагать в массиве от одной вершины к другой. С вершинами, надеюсь, понятно, теперь нужно понять, как сказать видеокарте, какие множества вершин образуют треугольники, т.к именно с треугольников, образующих поверхность mesh-а, и начинается rendering.
Общий принцип рисования 2D/3D объектов?
Чтобы видеокарта могла нарисовать наш mesh, мы должны ей сказать, какие множества вершин образуют треугольники. Самый простой способ это сделать - загружать последовательно все вершины нашего mesh-а в память видеокарты так, чтобы каждые три последовательно расположенные вершины образовывали треугольник. Тогда видеокарта могла бы идти с шагом в три вершины по массиву с аттрибутами вершин и выполнять рисовку.
Описанный способ рисования существутет во всех графических API, даже в самых старых, он достаточно прост, но, к сожалению, малоэффективен. Дело в том, что если нам нужно будет рисовать много разных mesh-ей, то под каждый из них в видеопамяти нужно будет выделить свой массив с аттрибутами вершин, после чего видеокарте при рисовке придется переключаться между массивами с аттрибутами вершин разных mesh-ей, что приведет к ее "простою" внутри цикла работы приложения в виду того, что переключение между массивами вершин занимает время.
В таком случае, возникает логичный вопрос: а можно ли как-то "слепить" все mesh-ы в один большой mesh, загрузить все разом на видеокарту и потом рисовать отдельные части этого mesh-а, если нужно ? Да, так делать можно и более того, такой подход фактически является стандартным в приложениях с 2D/3D графикой.
Рисование частей одного большого mesh-а, загруженного на видеокарту, называется индексированной рисовкой. Суть индексированной рисовки заключается в том, чтобы каждой вершине mesh-а присвоить индекс и далее, зная с какого по какой индекс лежит нужный нам mesh, внутри нашего большого mesh-а, попросить видеокарту нам его нарисовать через графический API:

Опытный читатель уже мог задаться вопросом о том, а зачем вообще индексированная рисовка, если можно попросить видеокарту нарисовать все разом, ведь все mesh-ы же лежат в одном большом массиве, так ? Дело в том, что к разным mesh-ам могут применяться разные геометрические преобразования типа поворота, отражения, масштабирования и.т.д. Поэтому, если вложенные mesh-ы внутри одного большого mesh-а имеют разные геометрические преобразования, то нарисовать все разом мы, к сожалению, не сможем. Но, зато мы сэкономим кучу процессорного времни на том, что нам не придется заставлять видеокарту переключаться между массивами с аттрибутами вершинин разных mesh-ей, т.к. они все будут лежать в одном массиве.
На самом деле, не только разные геометрические преобразования у разных mesh-ей являются препятствием к тому, чтобы рисовать их все разом. Есть еще нюансы, связанные с тем, как должен выполняться расчет цветов в промежуточных точках треугольников, образующих поверхность конкретного mesh-а: освещение, затенение и.т.д. Т.е, если алгоритмы расчета цветов в промежуточных точках треугольников, образующих поверхность конкретного mesh-а, разные, то нарисовать все mesh-ы, лежащие в одном большом массиве, за раз тоже не получится. Теперь, выясним, где же хранятся индексы вершин mesh-ей.
Индексы вершин хранятся в отдельном массиве, который также, как и массив с аттрибутами вершин mesh-а грузится на видеокарту. Т.е для использования механизма индексированной рисовки нам нужно загрузить на видеокарту сначала массив с аттрибутами вершин mesh-а, а потом еще загрузить массив с индексами этих вершин. Как только загрузка выполнена, можно функциями графического API просить видеокарту рисовать контекретный mesh, зная индекс его самой первой вершины и индекс самой последней вершины.
Теперь мы знаем, как вершины mesh-а хранятся в памяти видеокарты, как видеокарта понимает, какие множества вершин образуют треугольники и как загрузить все mesh-ы на видеокарту разом и рисовать нужные из них, используя индексы их первой и последней вершины. Теперь вопрос состоит в том, насколько часто надо обновлять mesh на видеокарте и насколько это эффективно ? Начнем с того, что подразумевается под обновлением mesh-а на видеокарте?
Обновление mesh-а на видеокарте - это удаление старого mesh-а и загрузка нового. В библиотеке Frenchie и подобных ей библиотеках типа ImGUI, обновление mesh-а на видеокарте реализуется каждый кадр, т.к mesh для рисуемых графических объектов постоянно перестраивается.
Опытный читатель наверняка уже задался вопросом о том, насколько эффективно загружать большие mesh-ы на видеокарту ? На самом деле, механизм загрузки работает довольно быстро, особенно, если грузить все разом (это еще один плюс в пользу механизма индексированной рисовки). Поэтому, на современных компьютерах mesh-ы размером даже до пары десятков тысяч треугольников можно загружать на видеокарту каждый кадр работы приложения без особой потери в производительности. Однако, загрузка/выгрузка больших 2D/3D моделей на каждом кадре действительно приведет к значительному снижению частоты кадров (frames per second - FPS). Поэтому, в тех же компьютерных играх применяется большое к-во оптимизаций, направленных на снижение количества рисуемых на экране объектов, а также на то, чтобы объекты с неменяющейся геометрией (mesh-ем) загружались на видеокарту один раз и рисовались, когда это необходимо. Но, не будем отвлекаться на компьютерные игры от основной темы и поговорим о геометрических преобразованиях.
Что такое матрицы геометрических преобразований и зачем они нужны?
Как мы говорили в прошлом разделе, вершины mesh-а имеют координату в пространстве. Чтобы поменять угол поворота, размер или расположение mesh-а мы можем нужным образом изменить координаты всех его вершин. Как это сделать ?
Координата вершины mesh-а - это вектор, а любое изменение координаты mesh-а - это, по сути, изменение положения вектора в 2D/3D пространстве. Расположение вектора в пространстве меняется путем умножения этого вектора на матрицу, называемой матрицей геометрических преобразований. Таким образом, матрицы геометрических преобрвазований как раз таки и позволяют применять различные геометрические преобрвазования к вершинам mesh-а (поворот, перемещение и.т.д).
В принципе, на этом можно было бы закончить, но, есть еще один важный нюанс, связанный с особенностями того, как матрицы геометрических преобразований применяются к 2D/3D объектам в компьютерной графике. Допустим, мы хотим нарисовать какой-то 3D объект. Объект у нас находится в 3D пространстве, но, монитор у нас 2D. Задача рисования в таком случае с точки зрения геометрии сводится к тому, чтобы спроецировать вершины mesh-а из 3D пространства в 2D пространство. В принципе, суть происходящего понятна, но помимо того, что монитор у нас 2D, есть еще один очень важный нюанс также связанный с монитором.
Мониторы существуют разных размеров и плотность пикселей на поверхности их экранов (к-во пикселей на единицу размера) тоже разная. Поэтому, чтобы задача проецирования точек из 3D в 2D решалась одинаково на всех мониторах производители видеокарт ввели понятие - нормированные координаты (Normalized Device Coordinates - NDC). Понятие нормированные означает, что координаты по всем трем осям - XYZ в NDC находятся в диапазоне от -1 до +1. Все, что выходит за границы указанного диапазона рисоваться не будет. Таким образом, задача проецирования точек из 3D в 2D пространство сводится еще и к тому, чтобы выполнить проекцию вершин mesh-а из 3D в 2D так, чтобы координаты наших вершин по всем трем осям уложились в диапазон от -1 до +1:

Вот теперь, для общего представления о том, зачем нужны матрицы геометрических преобразований, думаю, материала достаточно. Более подробную информацию о системах координат, используемых в компьютерной графике можно почитать здесь. О том, какие виды NDC систем координат существуют, а также о том, какие виды матриц проекций применяются, поговорим в отдельном материале, ибо это очень обширная тема. А сейчас предлагаю перейти к тому, как представляются текстуры.
Как представляются текстуры в памяти видеокарты? Что такое фильтрация и mipmap-ы?
В предыдущих разделах мы разобрались с тем, как моделируются вершины mesh-ей, как они хранятся в памяти видеокарты, а также с тем, что такое матрицы геометрических преобразований и как с их помощью вершины mesh-ей переносятся из 3D координат в 2D координаты. В этом разделе мы разберемся, как моделируются текстуры.
Текстура, по сути, представляет собой массив, в котором каким-то образом сохранены цвета каждого пикселя. Размер массива равен m x n x k, где m - ширина текстуры, n - высота, а k - количество цветовых каналов. Количество цветовых каналов определяется форматом текстуры. В компьютерной графике обычно используют форматы RBGA, RGB и Alpha, в которых имеется четыре, три и один цветовой канал соответственно. Для наглядности, массив с цветами текстуры на четыре канала размером 4x4 пикселя в памяти компьютера будет выглядеть так:

Помимно цветов, у текстуры также задают режим ее "схлопывания" по краям. Что имеется в виду ? Допустим, мы натягиваем текстуру на mesh из двух треугольников, образующих прямоугольник, а размер текстуры меньше, чем размер прямоугольника. В таком случае, какимм цветами нам заполнять те пиксели, цветов которых нет в массиве с цветами текстуры ? На практике, как правило, применяются всего четыре способа того, как это можно сделать.
Первый способ - взять цвета крайних пикселей текстуры и их цветами заполнить пространство вокруг текстуры, такой способ называется "схлопывание" - clamp.
Второй способ - это пиксели пространства, покрываемого текстурой, заполнить цветами пикселей текстуры, а остальное пространство вокруг текстуры заполнить прозрачным цветом. Такой способ называется "clamp zero" или "clamp to edge".
Третий способ - это взять, и повторить текстуру в пространстве вокруг нее - "repeat".
Четвертый способ, также связан с повторением цветов пикслей текстуры в пространстве вокрук текстуры, только повторяющаяся текстура отзеркаливается - "mirrorrepeat" или просто "mirror". Все четыре способа представлены на картинке ниже:

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

Линейная интерполяция - это когда renderer берет два ближайших пикселя из текстуры и считает цвет пикселя на мониторе как линейную интерполяцию между этими двумя цветами:

Визуальный эффект от описанных выше видов фильтрации текстур выглядит следующим образом:

С фильтрацией разобрались, теперь разберемся, что такое mipmap-ы ? Чтобы понять, зачем нужны mipmap-ы, нужно разобраться с тем, как renderer выбирает цвет пикселей из текстуры в случае, когда текстура высокого разрешения, а объект, на который она натягивается - маленького. При натягивании большой текстуры на маленький объект renderer вынужден каким-то образом выбрать цвет пикселя объекта из уменьшенной текстуры. В таком случае, почти всегда возникают графические артефакты из-за того, что алгоритмы фильтрации текстуры плохо работают в случае, когда пиксели текстуры очень мелкие и расположены очень близко друг к другу.
mipmap-ы - это, по сути, набор из копий одной и той же текстуры, в котором каждая последующая копия текстуры в два раза меньше предыдущей. При использовании mipmap-ов и масштабировании текстуры, из набора mipmap-ов берется нужная уменьшенная копия, что в комбинации с фильтрацией позволяет убрать графические артефакты при рисовке. Во многих графических API, при применении mipmap-ов и масштабировании текстуры есть возможность применить отдельные фильтры при уменьшении и увеличении размера текстуры.
Таким образом, мы разобрались с тем, как текстуры представляются в памяти, зачем нужна фильтрация и что такое mipmap-ы. Теперь перейдем непосредственно к тому, из каких этапов устроен процесс рисования.
Что такое rendering pipeline?
Rendering pipeline - это то, в каком порядке видеокарта рисует объекты на мониторе после того, как все mesh-ы и текстуры загружены на видеокарту и для них всех определены геометрические преобразования. Так в каком же порядке и что именно тогда делает видеокарта ?
Cначала, видеокарта применяет все геометрические преобразования к рисуемым объектам в результате чего, все их координаты оказываются в NDC координатах. Далее, видеокарта рассчитывает цвета на поверхностях треугольников рисуемых mesh-ей, после чего происходит смешивание цветов накладывающихся друг на друга полупрозрачных объектов. Как только все упомянутое сделано, производится обрезка всего, что выходит за границы NDC координат.
Описанный выше порядок рисования - это лишь набор основных действий, которые должна выполнить видеокарта, чтобы нарисовать нам что-то на мониторе. В зависимости от используемого графического API каждый из описанных выше этапов в rendering pipeline может разбиваться еще на несколько этапов, но, суть происходящего от этого не меняется.
Существует два типа rendering pipeline - фиксированный и программируемый. Фиксированный rendering pipeline - это такой pipeline, в котором мы никак не можем вмешиваться ни в один из этапов рисовки, мы можем лишь менять какие-то заранее определенные настройки. Такие виды pipeline-ов применялись в старых графических API типа DirectX9, либо старых версиях OpenGL.
Программируемый pipeline - это такой pipeline, в котором мы можем программировать самостоятельно отдельные этапы рисовки на свое усмотрение. Программируемые pipeline-ы являются промышленным стандартом и используются во всех современных графических API типа OpenGL, Vulkan, Metal.
С чего же начинать делать rendering backend?
Вооружившись базовыми знаниями о том, как работает rendering, мы можем наконец потихоньку начать разбираться в деталях. С чего же нам начать ? Начать нужно прежде всего с того, чтобы научиться инициализировать конкретный графический API. Что входит в процесс инициализации ? При инициализации графического API происходит создание состояния графического API и привязка этого состояния к контекстному окну. В процессе инициализации мы также должны создать буфферы для хранения аттрибутов вершин и индексов вершин. А как, собственно говоря, видеокарта узнает, какие аттрибуты имеют наши вершины ?
Большинство более-менее новых графических API, как правило, позволяют создавать так называемые дескрипторы атрибутов вершин (или что-то похожее), по информации из которых видеокарта будет понимать, какие атрибуты есть у вершин рисуемых mesh-ей. Т.е. перед тем, как создавать буферы с атрибутами вершин и индексами мы должны создать дескрипторы вершин или что-то похожее на них на конкретном графическом API. Проще говоря, мы должны сказать графическому API, из чего состоят наши вершины и создать буфферы под их хранение.
Далее, если rendering pipeline программируемый, то мы должны запрограммировать основные его этапы: применение геометрических преобразований и расчет цветов на поверхностях треугольников mesh-ей. Если же rendering pipeline не программируемый, мы должны правильно его настроить, чтобы геометрические преобразования и расчет цветов работали так, как мы того хотим. На этом, этап инициализации закончен. Далее, надо научиться непосредственно рисовать.
Прежде чем начать рисовать mesh, его нужно загрузить на видеокарту и подготовить графический API к рисованию. Как только mesh загружен, а графический API готов, мы можем механизмом индексированной рисовки рисовать нужные нам mesh-ы, зная индексы их первой и последней вершины. После того, как цикл индексированной рисовки закончен, мы можем попросить видеокарту отобразить результат рисовки на мониторе и сбросить состояние графического API в исходное.
Вот такой вот минимальный набор действий должен уметь выполнять rendering backend при помощи графического API, чтобы мы могли что-то нарисовать на мониторе. Про реализации rendering backend-а в библиотеке Frenchie с примером на конкретном графическом API рассмотрим в следующем материале. А пока, предлагаю закончить, надеюсь, было интересно.

