Урок №4. Рисуем наш первый треугольник в OpenGL

  Дмитрий Бушуев  | 

  Обновл. 9 Сен 2021  | 

 53114

 ǀ   40 

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

Графический конвейер

Процесс преобразования 3D-координат в 2D-пиксели управляется графическим конвейером (англ. «graphics pipeline») OpenGL. Графический конвейер можно разделить на две большие части:

   первая часть преобразует наши 3D-координаты в 2D-координаты;

   вторая часть преобразует 2D-координаты в реальные цветные пиксели.

Графический конвейер принимает в качестве входных данных набор 3D-координат и преобразует их в цветные 2D-пиксели на нашем экране. Если рассмотреть его функционирование более детально, то работу конвейера можно разделить на несколько этапов, где каждый следующий этап в качестве входных данных использует выходные данные из предыдущего этапа. Все эти этапы являются узкоспециализированными (т.к. имеют только одну конкретную функцию) и могут легко выполняться параллельно. В силу изначального проектирования современных видеокарт с акцентом на параллельные вычисления, они имеют тысячи небольших процессорных ядер для быстрой обработки наших данных в графическом конвейере. Процессорные ядра запускают небольшие программы на графическом процессоре для каждого этапа конвейера. Эти программы называются шейдерами (англ. «shaders»).

Некоторые из этих шейдеров могут быть настроены самим разработчиком, что позволяет нам написать свои варианты шейдеров (вместо указанных по умолчанию). Благодаря этому мы получаем возможность гораздо более точного контроля над определенными частями конвейера и, вследствие того, что они работают на графическом процессоре, экономим драгоценное процессорное время. Также стоит упомянуть, что шейдеры пишутся на языке GLSL (сокр. от англ. «OpenGL Shading Language»).

Ниже вы найдете абстрактное представление всех этапов графического конвейера. Обратите внимание, что на этапах, обозначенных синими секциями, мы можем использовать наши собственные шейдеры:

Как вы можете видеть, графический конвейер содержит большое количество этапов, каждый из которых обрабатывает одну конкретную часть преобразования элементов данных, задающих вершины будущего объекта, в полностью визуализированный пиксель. Чтобы вы лучше понимали, как работает конвейер, мы кратко рассмотрим каждый его этап.

Этап №0: В качестве входных данных для графического конвейера мы передаем массив Vertex Data[], содержащий список из трех 3D-координат, из которых впоследствии будет образован треугольник; эти данные представляют собой набор вершин.

Вершина (англ. «vertex») — это набор данных, задающих точку в 3D-пространстве. Информация об этой вершине представлена с помощью атрибутов вершин (англ. «vertex attributes»), которые могут содержать данные любого типа, но для простоты предположим, что каждая вершина состоит только из своей 3D-позиции в пространстве и некоторого значения цвета.

Примечание: Чтобы OpenGL знал, что делать с нашим набором координат и значением цвета, и во что они должны быть сформированы, необходимо ему подсказать, объекты какого типа мы ожидаем от него получить. Хотим ли мы, чтобы эти данные были представлены в виде набора точек, набора треугольников или, возможно, только одной длинной линии? Подобные подсказки называются примитивами (англ. «primitives») и передаются OpenGL при вызове любой из команд рисования. Такими подсказками-примитивами являются GL_POINTS, GL_TRIANGLES и GL_LINE_STRIP.

Этап №1: Первым этапом конвейера является вершинный шейдер (англ. «vertex shader»), принимающий в качестве входных данных отдельно взятую вершину. Основная цель вершинного шейдера — это преобразование 3D-координат в другие 3D-координаты (об этом мы поговорим чуть позже), а также вершинный шейдер позволяет нам выполнить базовую обработку атрибутов вершин.

Этап №2: Этап сборки примитива в качестве входных данных принимает все вершины (или одну вершину, если тип примитива задан как GL_POINTS) из вершинного шейдера, которые образуют примитив, и собирает все точки в заданной для примитива форме; в нашем случае данной формой является треугольник.

Этап №3: Выходные данные этапа сборки примитива передаются в геометрический шейдер (англ. «geometry shader»). Геометрический шейдер принимает в качестве входных данных набор вершин, образующих примитив, и имеет возможность генерировать другие фигуры, добавляя дополнительные вершины для формирования новых (или других) примитивов. В этом примере он генерирует второй треугольник из заданной формы.

Этап №4: Затем выходные данные геометрического шейдера передаются на этап растеризации (англ. «rasterization stage»), на котором результирующие примитивы сопоставляются с соответствующими пикселями на экране пользователя, в результате чего получаются фрагменты для использования фрагментным шейдером. Перед запуском фрагментных шейдеров выполняется обрезка. Обрезка удаляет все фрагменты, которые находятся вне нашего поля зрения, повышая производительность конечной программы.

Фрагмент в OpenGL — это все данные, необходимые OpenGL для визуализации одного пикселя.

Этап №5: Основная цель фрагментного шейдера (англ. «fragment shader») — это вычислить конечный цвет пикселя. Как правило, на этом этапе и возникают все продвинутые OpenGL-эффекты. Обычно фрагментный шейдер содержит данные о 3D-сцене, которые он может использовать для вычисления конечного цвета пикселя (например, свет, тени, цвет света и т.д.).

Этап №6: После того, как все соответствующие значения цвета будут определены, конечный объект пройдет еще альфа-тест и этап смешивания (англ. «blending stage»). На данном этапе проверяется соответствующее значение глубины (и трафарета) фрагмента, и далее они используются для проверки того, находится ли полученный фрагмент впереди или позади других объектов и должен ли он вследствие этого быть отброшен. На данном этапе также проверяется наличие альфа-значений (которые задают непрозрачность объекта) и, исходя из этого, объекты смешиваются соответствующим образом. В итоге, даже если выходной цвет пикселя уже был вычислен в фрагментном шейдере, конечный цвет пикселя при рендеринге нескольких треугольников все равно может стать совершенно другим.

Как вы можете видеть, графический конвейер представляет собой довольно сложный единый объект, состоящий из множества различных настраиваемых частей. Однако, в большинстве случаев нам придется работать только с вершинным и фрагментным шейдерами. Геометрический шейдер является необязательным и обычно остается с настройками «по умолчанию». Существует также еще несколько этапов графического конвейера, а именно: этап тесселяции (англ. «tesselation stage») и цикл трансформации фидбека, которые мы не изобразили на нашей схеме (оставим это на потом).

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

Ввод вершин


Чтобы что-то нарисовать, мы сначала должны передать OpenGL некоторые входные данные, описывающие вершины будущего объекта. Исходя из того, что OpenGL — это библиотека 3D-графики, то все координаты, которые мы указываем в OpenGL, являются точками в 3D-пространстве (с координатами X, Y, Z). OpenGL не просто преобразует все наши 3D-координаты в 2D-пиксели на экране; OpenGL обрабатывает 3D-координаты только тогда, когда они находятся в определенном диапазоне между -1.0 и 1.0 по всем трем осям (x, y и z). Все координаты в этом, так называемом, нормализованном диапазоне координат устройства в конечном итоге будут видны на нашем экране (а все координаты за пределами этой области — нет).

Поскольку мы хотим визуализировать один треугольник, то нам нужно указать в общей сложности три вершины, причем каждая из этих вершин имеет по 3 координаты. Для этого представим их в нормализованном диапазоне координат устройства (видимой области OpenGL), используя массив значений типа float:

Поскольку OpenGL работает в 3D-пространстве, а рендерить мы собираемся 2D-треугольник, то значением третьей координаты каждой вершины будет ноль (Z = 0.0). Таким образом, глубина треугольника остается неизменной, что делает его похожим на 2D-объект.

Нормализованные Координаты Устройства (сокр. «NDC» от англ. «Normalized Device Coordinates»)

После того как наши координаты вершин были обработаны в вершинном шейдере, они должны быть представлены в нормализованных координатах устройства, которые представляют собой небольшое пространство, где значения x, y и z варьируются от -1.0 до 1.0. Любые координаты, которые выходят за пределы этого диапазона, отбрасываются/обрезаются и не будут отображены на нашем экране. Ниже вы можете увидеть треугольник, который мы задали в пределах нормализованных координатах устройства (игнорируя ось z):

В отличие от обычных координат экрана, положительные точки оси y находятся выше расположенной горизонтально оси х, а координаты точки (0, 0) — в центре фигуры, а не в левом верхнем углу. В конечном итоге нам нужно, чтобы все (преобразованные) координаты оказались в данном координатном пространстве, иначе они не будут видны.

Затем NDC-координаты должны преобразовываться в координаты экранного пространства через преобразование окна просмотра с использованием данных, предоставленных функцией glViewport(). Далее полученные координаты экранного пространства преобразуются во фрагменты и задействуются в качестве входных данных для фрагментного шейдера.

Ввод вершин (продолжение)

После определения информации, необходимой для указания вершин, необходимо отправить её в качестве входных данных на первый этап графического конвейера — вершинный шейдер. Это делается путем выделения памяти на графическом процессоре, где мы собираемся хранить данные вершин, конфигурированием того, как OpenGL должен интерпретировать эту память и указанием того, каким образом отправлять данные на видеокарту. Затем вершинный шейдер обрабатывает столько вершин из своей памяти, сколько мы ему скажем.

Объект вершинного буфера (VBO)


Управление этой памятью происходит с помощью так называемых «объектов вершинного буфера» (сокр. «VBO» от англ. «Vertex Buffer Objects»), которые могут хранить большое количество вершин в памяти графического процессора. Преимущество использования этих буферных объектов заключается в том, что мы можем отправлять большие партии данных сразу на видеокарту и хранить их там (при наличии достаточного объема памяти), без необходимости отправлять данные по одной вершине за раз. Отправка данных от центрального процессора на видеокарту происходит относительно медленно, поэтому нужно стараться отправить как можно больше данных за один раз. Как только данные поступят в память видеокарты, вершинный шейдер будет иметь к ним почти мгновенный доступ, что сделает его чрезвычайно быстрым инструментом.

Объект вершинного буфера — это наше первое появление OpenGL-объекта. Как и любой другой объект в OpenGL, этот буфер имеет уникальный идентификатор, соответствующий данному буферу, поэтому мы можем создать его с идентификатором буфера, используя функцию glGenBuffers():

В OpenGL имеется большое разнообразие типов буферных объектов, например, объект вершинного буфера имеет тип GL_ARRAY_BUFFER. Также мы можем связываться сразу с несколькими буферами, имеющими другой тип буфера. Например, ничто не мешает нам привязать вновь созданный буфер к целевому типу GL_ARRAY_BUFFER с помощью функции glBindBuffer():

С этого момента любые вызовы буфера, которые мы делаем с параметром GL_ARRAY_BUFFER, будут использоваться для конфигурирования текущего связанного буфера, которым в вышеприведенном фрагменте кода является буфер VBO. Затем мы можем вызвать функцию glBufferData() и скопировать ранее определенные данные вершин в память буфера:

Функция glBufferData() специально предназначена для копирования пользовательских данных в текущий связанный буфер. Рассмотрим детально её вызов:

   Аргумент №1: Тип буфера, в который мы хотим скопировать данные — объект вершинного буфера, привязанный в данный момент к целевому типу GL_ARRAY_BUFFER.

   Аргумент №2: Определяем размер данных (в байтах), которые мы хотим передать в буфер; достаточно применить оператор sizeof к данным вершины.

   Аргумент №3: Данные, которые мы хотим отправить.

   Аргумент №4: Определяем то, как мы хотим, чтобы видеокарта управляла переданными ей данными. Это может иметь 3 формы:

   GL_STREAM_DRAW — данные указываются только один раз и используются графическим процессором не более нескольких раз.

   GL_STATIC_DRAW — данные указываются только один раз и используются много раз.

   GL_DYNAMIC_DRAW — данные часто изменяются и используются много раз.

Данные о позиции нашего треугольника не изменяются, используются много раз и остаются неизменными для каждого вызова рендеринга, поэтому в нашем случае как нельзя лучше подойдет тип использования GL_STATIC_DRAW. Если бы, например, у нас был буфер с данными, которые часто изменялись, то выбор типа использования GL_DYNAMIC_DRAW гарантировал бы, что видеокарта поместит данные в память, позволяющую быстрее записывать информацию.

На текущий момент мы храним данные наших вершин в памяти на видеокарте, управляемой объектом вершинного буфера с именем VBO. Далее нам необходимо будет создать вершинный и фрагментный шейдеры, которые будут непосредственно их обрабатывать.

Вершинный шейдер

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

Первое, что нам нужно сделать — это написать вершинный шейдер на языке шейдеров GLSL, а затем скомпилировать этот шейдер, чтобы мы могли использовать его в нашем приложении. Ниже вы найдете исходный GLSL-код очень простого вершинного шейдера:

Как вы можете видеть, язык GLSL очень похож на язык Cи. Каждый шейдер начинается с объявления его версии. Начиная с OpenGL 3.3 и выше, номера версий GLSL соответствуют версии OpenGL (например, GLSL версии 420 соответствует версии OpenGL 4.2). Мы также недвусмысленно упоминаем, что используем функции из core-profile.

Далее мы объявляем все входные атрибуты вершин в вершинном шейдере с помощью ключевого слова in. Прямо сейчас нас интересуют только данные местоположения, поэтому используем только один вершинный атрибут. Для этого в GLSL имеется векторный тип данных, который содержит от 1 до 4 переменных типа float (количество переменных задается постфиксной цифрой). Поскольку каждая вершина имеет 3D-координату, то мы создаем входную переменную типа vec3 с именем aPos. А также специально устанавливаем местоположение входной переменной через layout (location = 0). Позже вы увидите, для чего это понадобилось.

Примечание о векторе: В графическом программировании довольно часто используется математическое понятие вектора, поскольку он четко представляет позицию/направление объекта в пространстве любой размерности и обладает полезными математическими свойствами. Максимальный размер вектора в GLSL равен 4, и значение каждой его компоненты может быть получено с помощью выражений vec.x, vec.y, vec.z и vec.w, где каждое из них представляет собой соответствующую координату в пространстве. Обратите внимание, что компонента vec.w не используется в качестве представления позиции точки в пространстве (напоминаю, что мы имеем дело с 3D-пространством, а не с 4D-пространством), но используется для перспективного деления. Мы обсудим векторы гораздо более подробно на следующих уроках.

Чтобы задать выходные данные вершинного шейдера, мы должны присвоить данные позиции предопределенной переменной gl_Position, которая, на самом деле, имеет тип vec4. В самом конце функции main() всё, что мы задаем через gl_Position, будет использоваться в качестве выходных данных вершинного шейдера. Поскольку нашим входным объектом является вектор размерности 3, то мы должны привести его к вектору размерности 4. Это можно сделать, вставив значения типа vec3 в конструктор типа vec4 и установив его w-компоненту равной 1.0f (для чего это делается, будет объяснено на следующих уроках).

Текущий вершинный шейдер, вероятно, является самым простым вершинным шейдером, который мы можем себе представить, потому что мы вообще не обрабатывали входные данные и просто переслали их на выходные данные шейдера. В реальных же приложениях входные данные обычно еще не находятся в нормализованных координатах устройства, поэтому сначала мы должны преобразовать входные данные в координаты, которые попадают в видимую область OpenGL.

Компилирование вершинного шейдера


Мы берем исходный код для вершинного шейдера и помещаем его в константную строку в верхней части нашего кода:

Для того, чтобы OpenGL мог использовать шейдер, он должен динамически скомпилировать его из своего исходного кода во время выполнения программы. Первое, что нам нужно сделать — это создать объект шейдера с ссылающимся на него идентификатором. Для этого тип переменной вершинного шейдера объявим как unsigned int и создадим сам шейдер с помощью функции glCreateShader():

Далее передаем тип шейдера, который мы хотим создать, в качестве аргумента функции glCreateShader(). Поскольку нам нужен вершинный шейдер, то в качестве аргумента передаем GL_VERTEX_SHADER.

Затем прикрепляем исходный код шейдера к объекту шейдера и компилируем его:

Рассмотрим детально вызов функции glShaderSource():

   Аргумент №1: Объект шейдера для компиляции.

   Аргумент №2: Количество строк, которые мы передаем в качестве исходного кода (в нашем случае — это одна строка).

   Аргумент №3: Фактический исходный код вершинного шейдера.

   Аргумент №4: Этот аргумент мы оставляем равным NULL.

Вы, вероятно, хотите проверить, была ли успешной компиляция после вызова функции glCompileShader(), а если нет, то какие ошибки были найдены, чтобы вы могли их исправить. Проверка на наличие ошибок во время компиляции выполняется следующим образом:

Сначала мы определяем целое число для обозначения успешного исхода компиляции и контейнер для хранения сообщений об ошибках (если таковые имеются). Затем, с помощью функции glGetShaderiv(), мы проверяем, была ли компиляция успешной. Если компиляция не удалась, то мы должны извлечь сообщение об ошибке, используя функцию glGetShaderInfoLog(), и вывести его на экран:

Если при компиляции вершинного шейдера не было обнаружено никаких ошибок, то это значит, что он скомпилировался.

Фрагментный шейдер

Фрагментный шейдер — это второй и последний шейдер, который мы создадим для рендеринга треугольника. Фрагментный шейдер — это всё, что нам нужно для вычисления выходного цвета пикселей. Чтобы было проще, ограничимся тем, что фрагментный шейдер всегда будет выводить только оранжевый цвет:

Примечание: Цвета в компьютерной графике представлены в виде массива из четырех значений RGBA:

   R (от англ. «Red») = красный;

   G (от англ. «Green») = зеленый;

   B (от англ. «Blue») = синий;

   A (от англ. «Alpha») = альфа-компонент (непрозрачность).

При определении цвета в OpenGL или GLSL мы устанавливаем цветовую силу каждого компонента в диапазоне от 0.0 до 1.0. Если, например, мы установим красный цвет равным 1.0 и зеленый цвет равным 1.0, то получим смесь обоих цветов — желтый цвет. Учитывая эти 3 цветовых компонента (RGB), мы можем генерировать более 16 миллионов различных цветов!

Фрагментному шейдеру требуется только одна выходная переменная — вектор размерности 4, который задает конечный выходной цвет, который мы должны вычислить сами. Мы можем объявить выходное значение с именем FragColor, используя ключевое слово out. Затем мы просто присваиваем vec4 для вывода цвета в виде оранжевого цвета с альфа-значением 1.0 (значение 1.0 соответствует полной непрозрачности).

Процесс компиляции фрагментного шейдера аналогичен компиляции вершинного шейдера, хотя на этот раз в качестве типа шейдера мы используем константу GL_FRAGMENT_SHADER:

Теперь оба шейдера скомпилированы, и единственное, что нам остается сделать — это связать два шейдерных объекта в шейдерную программу, которую мы можем использовать для рендеринга. Обязательно по аналогии с прошлым разом проверьте данный фрагмент кода на наличие ошибок компиляции!

Шейдерная программа

Объект шейдерной программы — это финальная связанная версия нескольких шейдеров, объединенных вместе. Чтобы использовать недавно скомпилированные шейдеры, мы должны связать их с объектом шейдерной программы и затем при рендеринге объектов активировать её. Шейдеры активированной шейдерной программы будут использоваться при выполнении вызовов рендеринга.

При связывании шейдеров в шейдерную программу выводы каждого шейдера связываются с вводами следующего шейдера. Это также то место, где вы можете получить ошибки связывания, если ваши вводы и выводы не совпадают.

Создать объект шейдерной программы очень просто:

Функция glCreateProgram() создает программу и возвращает идентификатор ссылки на вновь созданный объект шейдерной программы. Теперь нам нужно прикрепить ранее скомпилированные шейдеры к объекту программы и с помощью функции glLinkProgram() связать их:

Данный код не должен вызывать у вас каких-либо трудностей, ведь мы просто прикрепляем шейдеры к шейдерной программе и связываем их с помощью функции glLinkProgram().

Точно так же, как и в случае с компиляцией шейдеров, мы можем проверить, не произошли ли какие-либо ошибки при связывании шейдерной программы, и, в случае чего, получить соответствующее сообщение. Однако, вместо того, чтобы использовать функции glGetShaderiv() и glGetShaderInfoLog(), мы теперь используем:

Результатом является программный объект, который мы можем активировать, вызвав функцию glUseProgram() с вновь созданным программным объектом в качестве его аргумента:

Теперь после активации glUseProgram() каждый вызов шейдера и рендеринга будет использовать данный программный объект (и, следовательно, шейдеры).

Кстати, не забудьте удалить шейдерные объекты, как только они будут связаны с программным объектом — нам они больше не нужны:

Прямо сейчас мы отправили входные данные вершин в графический процессор и проинструктировали его, как он должен их обрабатывать в вершинном и фрагментном шейдерах. Мы почти на месте, но не совсем. OpenGL все еще не знает, как он должен интерпретировать данные вершин в памяти и как необходимо соединять данные вершин с атрибутами вершинного шейдера. Ну что ж, давайте поможем OpenGL и расскажем ему, как это нужно сделать.

Связывание атрибутов вершин

Вершинный шейдер позволяет нам определить любые входные данные, которые мы хотим, в виде вершинных атрибутов, и, хотя это обеспечивает большую гибкость, это также означает, что мы должны вручную указать, какая часть наших входных данных идет к какому атрибуту вершины в вершинном шейдере. То есть, мы должны указать, как OpenGL должен интерпретировать данные вершин перед рендерингом.

Данные нашего вершинного буфера отформатированы следующим образом:

Рассмотрим это детально:

   данные о местоположении (т.е. координаты) вершин хранятся в виде 32-битных (4 байта) значений типа с плавающей точкой;

   каждая вершина состоит из трех таких значений;

   между каждым набором из трех значений нет пробела (или других значений). Значения плотно упакованы в массиве;

   первое значение данных местоположения вершин находится в начале буфера.

С помощью этих знаний мы можем сообщить OpenGL, как он должен интерпретировать данные вершин (для каждого атрибута вершины), используя функцию glVertexAttribPointer():

Функция glVertexAttribPointer() имеет довольно много параметров, поэтому давайте внимательно пройдемся по каждому из них:

   Аргумент №1: Указываем, какой атрибут вершины мы хотим настроить. Помните, что мы указали расположение позиции вершинного атрибута в вершинном шейдере при помощи layout (location = 0)? Благодаря этому, местоположение вершинного атрибута устанавливается в 0, и, поскольку мы хотим передать данные в этот атрибут вершины, мы передаем в качестве параметра значение 0.

   Аргумент №2: Определяем размер вершинного атрибута. Он имеет тип vec3, поэтому состоит из трех значений.

   Аргумент №3: Указываем тип данных GL_FLOAT (т.к. vec* в GLSL состоит из значений типа с плавающей точкой).

   Аргумент №4: Указываем, хотим ли мы, чтобы наши данные были нормализованы. Если мы вводим целочисленные типы данных (int, byte) и установили для них значение GL_TRUE, то целочисленные unsigned данные приводятся к 0 (или к -1 для signed типов данных) и к 1 при преобразовании в тип float. Это не имеет отношения к нашему примеру, поэтому мы оставим значение данного аргумента как GL_FALSE.

   Аргумент №5: Известен как шаг (англ. «stride») и говорит нам о пространстве между последовательными атрибутами вершин. Поскольку следующий набор данных местоположения вершины находится на расстоянии 3*sizeof(float), то указываем это значение как шаг. Обратите внимание, поскольку мы знаем, что массив плотно упакован (нет никакого пространства между следующим значением атрибута вершины), мы могли бы также указать величину шага как 0, чтобы позволить OpenGL самому определить шаг (это работает только тогда, когда значения плотно упакованы). Всякий раз, когда мы имеем дело с большим количеством вершинных атрибутов, нужно тщательно определять расстояния между соседними атрибутами (примеры подобных случаев мы рассмотрим несколько позже).

   Аргумент №6: Имеет тип void* и поэтому такое странное у него оформление. Это смещение того места, где начинаются данные в буфере. Поскольку данные находятся в начале массива данных, то это значение равно 0. Позже мы рассмотрим этот параметр более подробно.

Примечание: Каждый атрибут вершины берет свои данные из памяти, управляемой VBO. Из какого конкретно VBO он будет брать свои данные (у вас же может быть несколько VBO), определяется тем, какой VBO привязан к GL_ARRAY_BUFFER в момент вызова функции glVertexAttribPointer(). Поскольку ранее определенный VBO перед вызовом glVertexAttribPointer() все еще является связанным, то вершинный атрибут 0 теперь связан с его вершинными данными.

Теперь, когда мы определили то, как OpenGL должен интерпретировать вершинные данные, мы также должны задействовать атрибут вершины вместе с функцией glEnableVertexAttribArray(), передавая расположение вершинного атрибута в качестве аргумента (вершинные атрибуты по умолчанию не задействованы). С этого момента у нас всё настроено: мы инициализировали данные вершин в буфере с помощью объекта вершинного буфера, настроили вершинный и фрагментный шейдеры и объяснили OpenGL, как связать данные вершин с вершинными атрибутами вершинного шейдера. Рисование объекта в OpenGL теперь будет выглядеть примерно следующим образом:

Мы должны повторять этот процесс каждый раз, когда хотим нарисовать объект. На первый взгляд может показаться, что данный код выглядит довольно компактно, но представьте себе, что у вас есть более 5 вершинных атрибутов и, возможно, 100 различных объектов (что является не редкостью). Привязка соответствующих буферных объектов и настройка всех вершинных атрибутов для каждого из этих объектов быстро превратится в очень громоздкий процесс. Что, если бы существовал какой-нибудь способ сохранить все эти конфигурации состояний в объекте и просто связать этот объект, чтобы восстановить его состояние?

Объект вершинного массива (VAO)

Объект вершинного массива (сокр. «VAO» от англ. «Vertex Array Object») может быть привязан точно так же, как и объект вершинного буфера, и все последующие вызовы вершинных атрибутов с этого момента будут сохранены внутри VAO. Преимущество данного приема заключается в том, что при настройке указателей вершинных атрибутов нам нужно сделать эти вызовы только один раз, и всякий раз, когда мы хотим нарисовать объект, мы можем просто привязать соответствующий VAO. Благодаря этому переключение между различными данными вершин и конфигурациями атрибутов является таким же простым, как и привязка другого VАО. Всё то состояние, которое мы только что задали, сохранено внутри VАО.

Примечание: Ядро OpenGL требует от нас использования VAO для понимания того, что ему делать с нашими входными данными вершин. Если нам не удастся связать VАО, то OpenGL, скорее всего, откажется что-либо рисовать.

Объект вершинного массива хранит следующую информацию:

   вызовы функций glEnableVertexAttribArray() или glDisableVertexAttribArray();

   конфигурации вершинных атрибутов через функцию glVertexAttribPointer();

   объекты вершинного буфера, связанные с вершинными атрибутами вызовами функции glVertexAttribPointer().

Процесс создания VAO аналогичен процессу создания VBO:

Чтобы использовать VАО, вам нужно его связать с помощью функции glBindVertexArray(). С этого момента мы должны привязать/настроить соответствующие VBO и указатель(и) атрибутов, а затем отменить привязку VAO для последующего использования. Как только мы хотим нарисовать объект, мы просто связываем VАО с предпочтительными настройками, прежде чем нарисовать объект, и всё. Например:

И это всё! Всё, что мы сделали за последние несколько миллионов страниц, привело к этому моменту — VАО, который хранит нашу конфигурацию вершинных атрибутов, и выбор, какой VBO при этом использовать. Обычно, когда у нас есть несколько объектов, которые мы хотите нарисовать, мы сначала создаем/настраиваем все VAO (и, следовательно, необходимые VBO и указатели атрибутов) и сохраняем их для последующего использования. В тот момент, когда мы хотим нарисовать один из наших объектов, мы берем соответствующий VАО, связываем его с нужным объектом, затем рисуем объект и отменяем связывание VАО.

Треугольник, которого мы все так долго ждали

Чтобы нарисовать наши объекты, OpenGL предоставляет нам функцию glDrawArrays(), которая рисует примитивы, используя текущий активный шейдер, ранее определенную конфигурацию вершинных атрибутов и данные вершин из VBO (косвенно связанные через VAO):

Рассмотрим аргументы функции glDrawArrays() детально:

   Аргумент №1: Тип примитива OpenGL, который мы хотели бы нарисовать. Поскольку в самом начале я уже упоминал, что мы хотим нарисовать треугольник, то в качестве первого аргумента мы указываем GL_TRIANGLES.

   Аргумент №2: Начальный индекс вершинного массива, который мы хотели бы отобразить (мы просто оставляем его равным 0).

   Аргумент №3: Количество вершин, которые мы хотим нарисовать, а мы хотим нарисовать 3 вершины (мы визуализируем только 1 треугольник из наших данных, который имеет ровно 3 вершины).

Теперь попробуйте скомпилировать и запустить свой проект. Как только приложение скомпилируется, вы должны увидеть следующий результат:

Если ваш результат отличается от результата, изображенного на картинке, то вы, вероятно, что-то сделали не так. Проверьте свой исходный код, посмотрите, не пропустили ли вы что-нибудь (или добро пожаловать в комментарии после статьи).

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

Объекты элементного буфера (EBO)

Есть еще одна последняя вещь, которую нам нужно обсудить при рендеринге вершин — объекты элементного буфера (сокр. «EBO» от англ. «Element Buffer Objects»). Чтобы объяснить, как работают объекты элементного буфера, лучше всего привести пример: предположим, что мы хотим нарисовать прямоугольник вместо треугольника. Мы можем нарисовать прямоугольник, используя два треугольника (OpenGL в основном работает с треугольниками). Это приведет к созданию следующего набора вершин:

Как вы можете видеть, некоторые вершины перекрывают друг друга. Мы указываем нижнюю правую и верхнюю левую вершины дважды! Это создает нам проблему в виде накладных расходов в размере 50%, так как один и тот же прямоугольник может быть задан четырьмя вершинами, а не шестью. А это может привести к довольно ощутимым проблемам, как только мы получим более сложные модели, которые имеют более 1000 треугольников, где будут большие перекрывающие друг друга куски. Лучшим решением было бы хранить только уникальные вершины, а затем указать порядок, в котором мы хотим нарисовать эти вершины. В таком случае нам нужно будет сохранить для прямоугольника только 4 вершины, а затем просто указать, в каком порядке мы хотели бы их нарисовать. Разве не было бы здорово, если бы OpenGL предоставил нам такую возможность?

К счастью, объекты элементного буфера работают именно так. EBO — это буфер, очень похожий на объект вершинного буфера, который хранит индексы, используемые OpenGL для определения того, какие вершины нужно нарисовать. Этот, так называемый индексированный чертеж, и будет тем решением нашей проблемы, которое мы искали. Для начала нам необходимо указать (уникальные) вершины и индексы, чтобы нарисовать их в виде прямоугольника:

Вы можете видеть, что при использовании индексов нам потребуются только 4 вершины вместо 6. Далее нам нужно создать объект элементного буфера:

Подобно VBO, мы связываем EBO с массивом, содержащим координаты вершин, и копируем индексы в буфер с помощью функции glBufferData(). Кроме того, точно так же, как и с VBO, мы вставляем эти вызовы между вызовами связывания и отвязывания, хотя на этот раз в качестве типа буфера мы указываем GL_ELEMENT_ARRAY_BUFFER:

Обратите внимание, что теперь мы передаем GL_ELEMENT_ARRAY_BUFFER в качестве целевого буфера. Последнее, что нам осталось сделать — это заменить вызов функции glDrawArrays() на вызов функции glDrawElements(), чтобы указать, что мы хотим визуализировать треугольники из индексного буфера. При использовании glDrawElements() мы будем рисовать с использованием индексов, предоставленных в объекте элементного буфера EBO, связанного в данный момент:

Рассмотрим детально вызов функции glDrawElements():

   Аргумент №1: Определяем режим, в котором мы хотим рисовать (аналогично с функцией glDrawArrays()).

   Аргумент №2: Счетчик или количество элементов, которые мы хотели бы нарисовать. Мы указали 6 индексов, значит, необходимо нарисовать всего 6 вершин.

   Аргумент №3: Тип индексов, в нашем случае — это GL_UNSIGNED_INT.

   Аргумент №4: Смещение в EBO (или индексный массив, но это в том случае, если вы не используете объекты элементных буферов), которое мы оставим равным 0.

Функция glDrawElements() берет свои индексы из EBO, который в данный момент привязан к целевому объекту GL_ELEMENT_ARRAY_BUFFER. Это означает, что мы должны привязывать соответствующий EBO каждый раз, когда хотим визуализировать объект с индексами, что опять же немного неудобно. Так получилось, что объект вершинного массива также отслеживает привязки объектов элементного буфера. Последний объект элементного буфера, который привязывается во время привязки VАО, хранится как объект элементного буфера в VАО. Привязка к VАО затем также автоматически связывает и этот EBO.

Примечание: VАО хранит вызовы функции glBindBuffer(), когда целью является GL_ELEMENT_ARRAY_BUFFER. Это также означает, что он хранит свои вызовы отключения связей, поэтому убедитесь, что вы не отвязали буфер элементного массива перед тем, как отвязать ваш VAO, иначе он не будет иметь сконфигурированного EBO.

Полученный код инициализации и рисования теперь должен выглядеть примерно следующим образом:

Запуск программы должен дать изображение, как показано ниже. Первое изображение должно выглядеть уже знакомым, а второе — прямоугольник, нарисованный в каркасном режиме. Во втором прямоугольнике мы действительно видим, что он состоит из двух треугольников:

Примечание о каркасном режиме: С помощью вызова функции glPolygonMode(GL_FRONT_AND_BACK, GL_LINE) можно сконфигурировать OpenGL на отрисовку треугольников в режиме каркаса. Первый аргумент указывает на то, что мы хотим применить данный режим к передней и задней частям всех треугольников, а второй аргумент — нарисовать их в виде линий. Любые последующие вызовы функций отрисовки будут отображать треугольники в режиме каркаса до тех пор, пока мы не вернем первоначальные настройки с помощью функции glPolygonMode(GL_FRONT_AND_BACK, GL_FILL).

Если вам удалось нарисовать треугольник или прямоугольник точно так же, как и на предыдущем скриншоте, то вам удалось пройти одну из самых сложных частей современного OpenGL: нарисовать свой первый треугольник. Это трудная часть, так как необходим большой кусок знаний, прежде чем вы сможете нарисовать свой первый треугольник. К счастью, теперь мы преодолели этот барьер, поэтому дальше уже должно пойти проще.

  GitHub / Урок №4. Рисуем наш первый треугольник в OpenGL — Исходный код

Упражнения

Чтобы действительно получить хорошее представление об обсуждаемых концепциях, было создано несколько упражнений. Рекомендуется проработать их, прежде чем переходить к следующему уроку, дабы убедиться, что вы всё хорошо «переварили».

Задание №1

Попробуйте нарисовать 2 треугольника рядом друг с другом с помощью функции glDrawArrays(), добавив больше вершин к вашим данным.

Ответ №1

Задание №2

Теперь создайте те же 2 треугольника, используя два разных VAO и VBO для своих данных.

Ответ №2

Задание №3

Создайте две шейдерные программы, где вторая программа использует другой фрагментный шейдер, который выводит желтый цвет; нарисуйте оба треугольника снова, где один из них будет иметь желтый цвет.

Ответ №3

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (83 оценок, среднее: 4,94 из 5)
Загрузка...

Комментариев: 40

  1. Voyager_lqg7:

    Слишком большое начало, особенно для новичков. Конечно хорошо что всё досточно в мельчайших подробностях и без воды, но перечитывать этот урок придётся не один и ни два раза, поскольку запомнить такой объёмный пласт информации представляется очень сложным, для меня как для новичка лично. Написать простой треугольник самостоятельно без этого руководства, полный новичок вряд-ли сможет после прохождения этого урока. Ну… что же поделать придётся сидеть пыхтеть.

  2. Сергей Федоров:

    Дмитрий, спасибо большое за Ваш труд!
    Этот урок тяжко зашёл. Вроде бы разобрался, даже домик нарисовал из одного VBO и трёх VAO c EBO и четырёх фрагментных шейдеров. Но всё где-то на интуитивном уровне. До полного понимания чего-то не хватает, практики наверное.
    Не совсем чётко уловил взаимосвязь VBO и VAO.

  3. Евгений:

    Очень редко что либо пишу в интернете, за всю жизнь наверное не написал и 10 постов, но тут не выдержал 🙂

    Огромное спасибо за создание ресурса и подход к делу. Вы не только выложили качественный материал, но и сделали качественную верстку, которую приятно и понятно читать. Я не занимаюсь именно 3d графикой, но по ходу своей профессиональной деятельности делал периодически попытки освоить OpenGL, но т.к. не было в этом острой необходимости с одной стороны и разрозненности материала по теме при достаточно большом пороге вхождения с другой стороны, я так и не мог преодолеть этот порог. И только попав на Ваш ресурс, я смог легко и приятно преодолеть этот порог :).

    Ещё раз огромное спасибо за Вашу работу и подход к делу!

    1. Фото аватара Юрий:

      Пожалуйста)) Очень круто, что Равесли оказался Вам полезным))

  4. artem:

    Здравствуйте, недавно решил начать разбираться во всей этой теме, но возникла проблема:
    аргумент типа "const char**" несовместим с параметром типа "GLsizei*"
    Не знаю что с этим делать.

    1. Taras:

      artem, советую вам сначала прочитать статью об указателях и типах данных.

    2. Фото аватара Дмитрий Бушуев:

      Проверил у себя, всё работает.
      P.S.: В каком конкретно месте возникает ошибка?

    3. fidius:

      Вы первое ошиблись с функцией
      у вас glGetShaderSource, а не glShaderSource
      Второе у вас во вершинном шейдере в layout-переменных одни и те же местоположения

  5. Алексей:

    После долгого осознания того, что тут написано, я вроде понял. Завтра попробую сделать задания к уроку. Спасибо за этот урок!)
    P.S. Я только не понял, почему мы не должны отключать EBO во время привязке его к VAO? Я просто это сделал и у меня на окне с треугольником вылетало окно с завершением работы программы. В отладчике я проверил, почему, и ошибка вылетала на моменте уже рисования треугольника, т.е. в цикле while. После того, как я убрал отвязываение (просто присвоил вместо EBO ноль) всё заработало.

  6. Илья:

    "Кстати, не забудьте удалить шейдерные объекты, как только они будут связаны с программным объектом — нам они больше не нужны:"

    А можно ли эти шейдеры дальше было использовать чтобы создать другие связки с программным объектом? Или они в принципе одноразовые и если нам нужен этот же шейдер на другом объекте то мы должны заново его компилировать?

  7. Илья:

    Ваш текст безумно похож на текст из мануала по OpenTK даже картинки теже, мне просто интересно на основании какого материала вы создавали свой материал? Мне почему то кажется что у того руководства и у вашего одни и теже корни. я кстати планирую тоже самое делать но только на SharpGL, если это действительно порт то в теории повторить ваш урок на том же самом C# получится, я по крайней мере на это надеюсь.

    1. Фото аватара Юрий:

      Это перевод с https://learnopengl.com/. На странице с уроками по OpenGL это указано.

  8. Евгений:

    Здравствуйте. Вот очень интересует такой вопрос — в таких фрагментах кода, как "int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);" — правильно ли я понимаю, что в данном случае выполняется приведение типов, как указатель к int? Поясню ход мыслей: чисто технически, обычный указатель является переменной типа unsigned int, и его значение — это порядковый номер ячейки оперативной памяти объёмом 4 байта( в 32-разрядных системах так точно). Если в данном примере glCreateShader() возвращает int именно в контексте "использовать как указатель", то не правильнее ли было бы применять "unsigned fragmentShader", дабы избежать ошибок обращения по отрицательным индексам памяти( помним, что инт знаковый тип)? Или glCreateShader() всегда возвращает беззнаковый тип? У меня успешно компилируется с "uint32_t fragmentShader". Заранее спасибо)

    1. VADIM:

      Значение, возвращаемое glCreateProgram() и glCreateShader() не является указателем ни в каком смысле, это просто номер объекта, его имя, идентификатор. Оно должно быть беззнаковым, так что тут просто опечатка.

  9. Роман:

    Здравствуйте.
    Я писал длинный комментарий минут 20, но потом закрыл браузер и не отправил. Он стёрся. Буду краток. Хочу написать класс Triangle, позицию и цвет каждой из вершин которого можно будет менять программно. Когда я использую glDrawArrays через uniform не выйдет, ибо он всё отправляет одним потоком. Как это сделать?
    Т.е. возможный код таков:

    ?

    1. Роман:

      Вот накидал С-шный код, проверьте пожалуйста:

      Будет ли он работать так, как я хочу?

  10. Кетчуп:

    Добрый день!
    Спасибо огромное за уроки!
    Единственное что меня мучает, это то как разбить этот один огромный файл на несколько меньших? Буду благодарен если посоветуете как так сделать.

    1. Фото аватара Дмитрий Бушуев:

      Как по мне — не вижу смысла его дробить, т.к. это приведет к ухудшению читабельности (и как следствие — ухудшение понимания работы) кода.

      1. Кетчуп:

        Окей, спасибо!

  11. Andrey:

    Добрый день! Спасибо огромное за ваш труд! Есть такой вопрос.

    1. Могу ли я привязать несколько VBO к одному VertexArrayID?
    2. Если да, а вроде как могу, ну по крайне мере технически, то как их потом отрисовать?

    Например:

    1. Roman:

      коротко — никак. VAO придумали, что бы сократить подготовку к отрисовки одного объекта который использует конкреный набор VBO, и IBO(если нужен). А не наоборт.

  12. noname:

    Привет. Возникла проблема, я написал загрузчик для шейдеров, который принимает имя файла, ну а дальше по сценарию из урока. Так вот при попытке скомпилировать мне выдаёт такие ошибки

    Vertex shader failed to compile with the following errors:
    ERROR: 0:? : error(#76) Syntax error: unexpected tokens following #version
    ERROR: 0:? : error(#364) Invalid: unexpected token in symbol.
    ERROR: error(#273) 2 compilation errors. No code generated

    Код шейдера абсолютно тот же, что в уроке. Не могу понять в чём проблема.

    1. Фото аватара Дмитрий Бушуев:

      В каком редакторе набирался код шейдеров?

      1. noname:

        Еще раз приветствую. Проблема была в том, что я неправильно загружал код шейдеров из файла (плохо знаю C++).

        1. Фото аватара Дмитрий Бушуев:

          Хорошо что всё разрешилось 🙂

  13. Andrey:

    Добрый день! А зачем нужно вызывать glBindVertexArray(VAO); 2 раза. А потом и с нулем glBindVertexArray(0);?

    1. Фото аватара Дмитрий Бушуев:

      Здравствуйте.
      Начну с последнего — glBindVertexArray(0);
      Это сделано чтобы каждый раз не отвязываться от VAO. Привязавшись к 0 мы гарантируем, что не произойдет никаких изменений, затрагивающих наш VAO (OpenGL никогда не создаст VAO со значением 0).

      Насчет вызова glBindVertexArray(VAO) дважды.
      Это вы про конкретный участок в статье или в архиве с исходными кодами? Потому, как в статье, в местах вставок фрагментов кода, некоторые маловажные в данный момент строчки кода заменены символом пропуска "[…]". А если посмотреть в исходниках, то там (если я не ошибаюсь), каждому вызову glBindVertexArray(VAO) соответствует вызов glBindVertexArray(0) (за исключением цикла рендеренга). Ну а вообще, несколько вызовов glBindVertexArray(VAO) — это своеобразная страховка, что вы действительно привязали VAO пере использованием 🙂

      1. Andrey:

        Спасибо большое за ответы и проделанную работу! Один момент еще возник.

        Вот есть код для генерации и привязки данных для вершин

        Есть код для привязки объекта вершинного массива (VAO)

        А где код, который связывает VAO с VBO.

        Ну, т.е. с привязкой и соединением шейдеров все понятно там есть приграмма которыя все это объединяет.

        То, где то место в коде которое объединяет и как-то связывает обе этих величины — VAO с VBO?

        Или это где-то внутни под капотом. Типа
        тут мы открываем окно

        Все что тут положилось само внутри привязалось и потом мы закрыли это окно

        Как они узнают о существовании друг друга эти величины?

        1. Фото аватара Дмитрий Бушуев:

          Добрый день.
          Посмотрите еще раз внимательно абзац "Объект вершинного массива (VAO)" и картинку к нему.

          Когда мы вызываем glVertexAttribPointer(), OpenGL автоматически устанавливает текущий связанный VBO в качестве буфера памяти атрибута вершины, поэтому все последующие вызовы отрисовки будут использовать данные вершины из памяти VBO, используемой в соответствии с glvertexattribpointer().

  14. Джек:

    Привет! Спасибо за уроки.
    Раньше писал под OpenGL 2.3. В новых версиях концепция сильно поменялась, давно хотел разобраться в шейдерах и программах отрисовки. Ваш материал хорошо разжеван. Надеюсь, дальше будет интереснее 😉

    1. Джек:

      И сразу разрешите вопрос. Вы смогли собрать проект на маке?
      У меня macOS Mojave 10.14. Компиляция прошла успешно, но с ворнингами об устаревшей версии.


      warning: 'glCreateShader' is deprecated: first deprecated in macOS 10.14 - OpenGL API deprecated. (Define GL_SILENCE_DEPRECATION to silence these warnings) [-Wdeprecated-declarations]
      int vertexShader = glCreateShader(GL_VERTEX_SHADER);

      Связанные ошибки получил в runtime:


      ERROR::SHADER::FRAGMENT::COMPILATION_FAILED:
      ERROR: 0:1: '' : version '330' is not supported
      ERROR: 0:1: '' : syntax error: #version
      ERROR: 0:2: 'layout' : syntax error: syntax error

      Использую либы из XCode версия SDK MacOSX10.15.
      Поиск не особо помог, предлагают установить более старый XCode с поддержкой прежних версий SDK. Как по мне, это скучный вариант.

      Возможно, что-то можете посоветовать?

      1. Фото аватара Дмитрий Бушуев:

        >>У меня macOS Mojave 10.14
        В соответствии с этим новостями…:
        https://developer.apple.com/macos/whats-new/
        https://developer.apple.com/ios/whats-new/
        https://developer.apple.com/library/archive/documentation/GraphicsImaging/Conceptual/OpenGL-MacProgGuide/opengl_intro/opengl_intro.html

        …Apple объявила OpenGL — устаревшей технологией, начиная с macOS 10.14. Они хотят, чтобы вы переходили на "металл":
        https://developer.apple.com/metal

        >>Возможно, что-то можете посоветовать?
        Нуу, вариантов выхода из сложившейся ситуации не так много:
        1. Использовать "метал", как советует Apple;
        2. Не использовать MacOS 🙂

        1. Dmitry:

          По факту opengl продолжает работать но сыпет ворнинги.
          Их можно побороть если писать #define GL_SILENCE_DEPRECATION
          Верху файлов

      2. jack:

        мак поддерживает opengl до 4.1 версии
        если используете glfw, то явно укажите следующие строки кода (после инициализации glfw)

        и соответственно в шейдерах используйте

        по дефолту opengl на маке в compatibility profile (версия без шейдеров), нужно явно указывать что хочешь использовать core

  15. Фото аватара Дмитрий Бушуев:

    Добрый день.
    Попробуйте посмотреть в сторону этих книг:
    Вольф Д. — OpenGL 4. Язык шейдеров. Книга рецептов ( ДМК Пресс, 2015 )
    Гинсбург Д. — OpenGL ES 3.0. Руководство разработчика

    1. Джек:

      Разрешите свои 5 копеек вставить.
      У меня стаж в OpenGL 2 чуть больше 5 лет, работаю с визуализацией процессов в КИПиА и УФС. И Гинсбург у меня не зашел, показалось слишком сложно.

      Возможно, что-то посоветуете из свежей литературы?

      Еще айн вопрос. Какую версию OpenGL стоить выбрать для изучения, чтобы не сильно отставать от современности, но и поддерживать некоторые породы динозавров?

  16. Арбузик❤❤❤:

    Один из лучших уроков! Спасибо, что делаете для нас это 🙂

    1. Фото аватара Дмитрий Бушуев:

      Пожалуйста 🙂

  17. nullptr:

    Здравствуйте! Не могли бы Вы подсказать литературу по OpenGL.

    1. Фото аватара Дмитрий Бушуев:

      Добрый день.
      Видимо я промахнулся с ответом на ваш комментарий. Выше я привел несколько книг по OpenGL 🙂

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

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