Hello OpenGL

Введение

В этой небольшой статье мы попробуем разработать простейшее приложение с использованием OpenGL. Мы будем использовать современную версию OpenGL (3.2+, так называемая modern OpenGL). В качестве основного языка программирования мы будем использовать C++ привкусом C. Проект будет основан на CMake, поэтому можно использовать любую IDE поддерживающую CMake проекты нативно (например, VS CE, CLion и другие), либо, можно сгенерировать проект для вашей IDE, используя утилиту CMake. Основной целевой платформой будет Windows (Win 10, но проблем с другими современными версиями этой ОС, по идее, быть не должно). Исходный код можно найти на репозитории.

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

Лучший способ чему-то научиться – начать учить этому других (народная мудрость).

Disclaimer
  • еще одна статья об OpenGL;
  • с вероятностью 146% мною будут допущены те или иные ошибки, поэтому, конструктивная критика, правки и предложения приветствуются.

OpengGL

OpenGL (Open Graphics Library) — открытая кроссплатформенная графическая библиотека для написания графических приложений.

Строго говоря, хотя в названии и присутствует слово библиотека, OpenGL библиотекой не является. Фактически, OpenGL это спецификация на бумаге (иными словами графический API), описывающая какие функции реализация должна содержать и то, к чему вызов этих функций приведет. Конкретная же реализация этой спецификации зависит от производителя драйверов вашей видеокарты. Само собой у каждого производителя она отличается и вы не сможете использовать реализацию OpenGL от NVIDIA на видеокарте, например, от Intel, т.к. реализация «завязана» на соответствующих  особенностях оборудования (более того, она, скорее всего, частично или полностью, выполнена на уровне железа).

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

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

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

 

Примерный план

  • Инициализировать контекст отрисовки OpenGL;
  • Вывести треугольник.

Реализация

Обычно (для подключения большинства библиотек) достаточно подключить заголовочный файл библиотеки  #include <gl.h>  и добавить зависимость компоновщику  opengl32.lib и, иногда, вызвать метод инициализации  glInitialize(...) . Но исторически так сложилось, что процесс инициализации OpenGL библиотеки «несколько» усложнился…

Историческая справка

Из-за событий (таких как это письмо или ответ на него) началась кампания Microsoft против OpenGL в угоду их собственной проприетарной разработки DirectX. В 2003 году Microsoft покидает «Наблюдательный совет за архитектурой OpenGL (ARB OpenGL), а в 2005 году на SIGGRAPH они объявили о намерении удалить поддержку OpenGL из Windows Vista, оставляя лишь слой эмуляции поверх DirectX для обратной совместимости. В 2006 году члены Khronos Group (такие как NVIDIA, AMD) объявили о поддержке OpenGL в клиентских драйверах.

Результатом этих API «войн» ОС Windows поддерживает OpenGL только версии 1.1.  А для использования последних возможностей OpenGL необходимо устанавливать драйверы от производителей видеоускорителей (NVIDIA, AMD или Intel).

Тоже самое относится и к заголовочным файлам OpenGL. Windows SDK содержит заголовочные файлы OpenGL только версии 1.1. А для использования современных возможностей OpenGL необходимо использовать заголовочные файлы предоставляемые Khronos Group.

[свернуть]

Для получения доступа к современным функциям OpenGL нам нужно получить указатели на эти функции во время выполнения программы используя функцию  wglGetProcAddress()  из библиотеки  opengl32.dll  версии 1.1. Этот процесс похож на процесс подключения динамической библиотеки с  LoadLibrary() и  GetProcAddress() . Обычно для получения функций OpenGL используются соответствующие библиотеки, такие как GLEW, GL3WglLoadGen и другие. А для того, чтобы разобраться как эти библиотеки работуют мы не будем их использовать и реализуем весь необходимый функционал вручную.

Но прежде чем получить указатель на функцию OpenGL нужно создать OpenGL контекст отрисовки. А для этого, в свою очередь, нужно:

  • создать окно;
  • получить контекст устройства (Device Context, DC) с помощью GetDС();
  • выбрать формат пикселей контекста устройства (с аппаратным ускорением!) с помощью ChoosePixelFormat()  и ассоциировать его с контекстом устройства (DC);
  • создать OpenGL контекст отрисовки с помощью  wglCreateContext().

После чего, мы сможем получить указатели на необходимые функции OpenGL и, наконец-то, начать их использовать.

Выглядит все относительно просто, но есть небольшая загвоздка (собственно, как чаще всего и бывает): методы ChoosePixelFormat()  и wglCreateContext()  не расширяемые, иными словами, они не поддерживают новые возможности OpenGL. Например,  метод ChoosePixelFormat() использует структуру  PIXELFORMATDESCRIPTOR в которой нет поля, которое можно выставить, чтобы запросить мультисэмлинг (сглаживание), sRGB или floating-point формат фреймбуфера. А используя  wglCreateContext()  не получится указать OpenGL профиль или версию OpenGL.

Для решения этой проблемы существуют функции wglChoosePixelFormatARB() и  wglCreateContextAttribsARB() соответственно. Причем обе эти функции принимают список атрибутов, таким образом поддерживая любое количество опций. Но, чтобы создать контекст отрисовки используя эти функции нам нужен (тадатам!) контекст отрисовки, чтобы получить указатели на эти функции(. Само собой и эта проблема решаема. Для этого нам придется создать временное окно и временный контекст отрисовки, затем получить указатели на необходимые нам функции и, далее, создать новое окно и контекст отрисовки с необходимыми параметрами.

Начнем с создания окна. Обычно, в подобных уроках, для создания окон используются специализированные библиотеки (например: SDLSFMLGLFW и т.п.). Само собой мы пойдем своим путем разбираясь как делать все ручками и, за одно, поймем (надеемся) почему, обычно, так не делается и нам тоже не стоило.

Если кратко, то для создания окна необходимо:

  • описать основную оконную процедуру  WndProc() для обработки оконных событий. На данный момент достаточно обработать событие уничтожения окна  WM_DESTROY , по которому завершаем работу приложения;
  • зарегистрировать класс окна с помощью  RegisterClassEx() , указав ранее описанную оконную процедуру в качестве обработчика оконных событий;
  • создать окно с помощью  CreateWindow() ;
  • показать и обновить окно  ShowWindow() и  UpdateWindow() ;
  • создать очередь обработки оконных событий, используя  GetMessage() ,  TranslateMessage() и  DispatchMessage() .

Более подробно о работе с оконным API можно прочитать, например, здесь.

Если у вас уже был опыт работы с Window API, то код ниже вам должен быть, до боли, знаком. Для облегчения чтения программы разобъём код на несколько файлов:

А чтобы это все можно было скомпилировать, необходимо указать в CMake, что мы хотим сборку под Win32. Сделать это можно путем добавления флага  WIN32 команды  add_executable :

Скомпилировав и запустив наш код мы должны увидеть простое пустое окно с заголовком «Hello OpenGL».

Наподобии такого:

Надеемся, что все так, тогда продолжаем.

Теперь нам нужно подключить заголовочные файлы OpenGL. Нам потребуются:  <GL/gl.h> (идет в комплекте с Windows SDK) и  "GL/glext.h" и "GL/wglext.h" (которые можно скачать в Khronos OpenGL реестре). А с недавних пор стал требоваться еще и  <KHR/hkrplatform.h>  (который можно скачать в Khronos EGL реестре). И подключаем заголовочные файлы с помощью CMake команды target_include_directories:

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

Теперь мы можем создать временное окно и получить контекст устройства:

Далее подбираем подходящий формат пискелей контекста устройства и получаем его идентификатор формата. И если удалось получить идентификатор, то ассоциируем его с контекстом устройства временного окна:

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

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

Для получения указателя на функцию нужно найти соответствующий ей  typedef (фактически сигнатуру функции) в заголовочных файлах OpenGL. Затем получить указатель на функцию по имени с помощью функции  wglGetProcAddress() и откастить его к соответствующей сигнатуре функции. После чего проверить результат на успешность.  gl_binding.cpp :

Создаем временный контекст отрисовки и инициализируем OpenGL биндинги:

И аналогично для  wglCreateContextAttribsARB() :

И весь этот мучительно долгий, трудный и опасный (очень легко ошибиться) процесс нужно повторить для КАЖДОЙ используемой OpenGL функции, которая появилась позже OpenGL 1.1. Именно по этому, чаще всего, используются разные библиотеки-загрузчики, делающие всю «грязную» работу за вас.

Именование типов функций

Если хорошо приглядеться, к имени типа функции PFNWGLCHOOSEPIXELFORMATARBPROC можно разбить на осмысленные части: PFN — pointer function (указатель на функцию); — WGLCHOOSEPIXELFORMATARB — wglChoosePixelFormatARB название нашей функции (а ARB это сокращение Architecture Review Board или Наблюдательный совет за архитектурой OpenGL); PROCprocedure (процедура). 

[свернуть]

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

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

Хотя мы и получили идентификатор формата пикселей наше новое окно по прежнему требует вызова стандартной функции  SetPixelFormat()  с назначением старой версии дескриптора  PIXELFORMATDESCRIPTOR . Благо, мы можем получить его из уже имеющегося идентификатора:

Теперь мы готовы создать настоящий контекст отрисовки OpenGL, для примера, указав основной профиль и версию OpenGL 4.5 (более подробно с профилями можно ознакомиться здесь):

Самое время избавиться от временного окна и всего, что с ним связано:

Для того, чтобы скомпилировать вышенаписанный код, необходимо прилинковать OpenGL:

Если в таком виде запустить приложение, то появится окно и тут же закроется. Происходит это из-за того, что в основной оконной процедуре, а она у нас общая (для всех окон приложения), есть реакция на событие  WM_DESTROY , по которому мы завершаем работу приложения. Вместо реакции на событие  WM_DESTROY , которое рассылается по уничтожению окна (в нашем случае, при уничтожении временного окна) мы можем реагировать на событие закрытия окна  WM_CLOSE . Так же, можно, в основной оконной процедуре, проверять какое окно уничтожено используя параметр  hWnd .

Активируем текущий контекст отрисовки:

Закончим реализацию текущего пункта плана заливкой экрана цветом используя OpenGL:

Команда  glClearColor() задает цвет заливки в формате RGB, команда  glClear() с параметром  GL_COLOR_BUFFER_BIT очищает цветовой буфер, закрашивая каждый пиксель ранее заданным цветом. Последняя команда меняет местами текущий и фоновый буферы, тем самым, отображая, наши изменения. При создании контекста отрисовки мы указывали атрибут  DOUBLE_BUFFER , который создает два буфера. Это своего рода оптимизация, позволяющая не отображать еще полностью не сформированную картинку. На одном буфере мы рисуем, а другой, в текущий момент, отображается, как только мы закончили рисование, то меняем буферы местами.

Запустив приложение, должно показаться окно, полностью залитое светло синем цветом. Наподобии такого:

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

 

Отрисовка треугольника

Следующим пунктом нашей программы является вывод треугольника на экран. Треугольник является одним из примитивов OpenGL, к которым так же относятся линии и точки. Треугольники являются, пожалуй, самыми часто-используемыми примитивами в 3D графике. Современная версия OpenGL использует программируемый пайплайн (конвейер — последовательность команд для вывода отображения на экран с возможностью вклиниться в этот процесс в точках расширения), по этому для отрисовки даже простого треугольника необходим (как минимум) один шейдер (мы будем использовать два). Думаем мало кто сегодня не слышал этот термин. Фактически, шейдеры это микропраграммы написанная на одном из шейдерных языков (например, как в нашем случаей, на GLSL) и выполняемые видеокартой (видеоускорителем). Существует несколько типов шейдеров, нас в первую очередь, будут интересовать вершинные (vertex shader) и фрагментные (fragment shader или пиксельные шейдеры — pixel shader) шейдеры. Вершинные шейдеры оперируют, как не трудно догадаться, с вершинами треугольников (координаты вершины в пространстве) и различными данными, связанными с этими вершинами: текстурными координатами, цветом вершин, векторами нормалями и бинормалями, расчетом освещения, перспективным преобразованием и т.д. и т.п. Фрагментные шейдеры, в свою очередь, работают с фрагментами растрового (уже растрированного) изображения и со связанными с пикселями данными: цветом, глубиной, текстурными координатами и т.д. Вершнинный шейдер вызывается для каждой вершины отрисовываемого объекта. При отрисовке одного треугольника программа вершинного шейдера будет выполнена 3 раза для каждой вершины треугольника соответственно. Фрагментный шейдер вызывается для каждого растрированного фрагмента (для упрощения можно считать пикселя) попадающего в треугольник. Таких фрагментов, даже для одного треугольника, много, соответственно и вызовов выполнения фрагментных шейдеров также много.

Начнем с самих шейдеров. Шейдеры это просто текстовые файлы (с неустоявшимся расширением) с кодом шейдера. Для вершинных шейдеров мы будем использовать расширение  vert , а для фрагментных  frag соответственно.

Наш наипростейший вершинный шейдер:

Язык шейдеров GLSL своего рода урезанная и заточенная под написание шейдеров версия языка со своими особенностями.  В начале текста шейдера мы указываем версию шейдера. Далее описывается входящий параметр (модификатор in) с типом ( vec4  — вектор 4  x, y, z, w): собственно данные обрабатываемой вершины. В результате своей работы вершинный шейдер должен, как минимум, вернуть итоговое положение вершины. Для этого используется зарезервированная переменная  gl_Position . Так как у нас элементарный шейдер, то и никаких дополнительных действий с вершиной мы производить не будем — записываем в результат работы шейдера входящую вершину без каких-либо модификаций.

Наш наипростейший фрагментный шейдер выглядит так:

В результате своей работы фрагментный шейдер должен вернуть цвет, обработанного фрагмента. Для реализации этого функционала используется переменная (с модификатором out) и уже знакомым типом vec4, который в данном случае, представляет из себя цвет в формате r g b a. Для создания цвета мы используем функцию vec4, передавая в неё, соответствующие значения цвета. В данном случае это желтый цвет.

Чтобы наша программа смогла найти шейдеры нужно добавить соответствующую команду CMake, чтобы при каждой компиляции файлы шейдеров автоматически копировались в выходную директорию:

Само-собой одних файлов шейдеров недостаточно. Необходимо создать соответствующий OpenGL объект программы шейдера. Алгоритм создания этой программы выглядит так:

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

Выглядит алгоритм слегка устрашающе. Начнем его реализаци с функции чтения содержимого файла шейдера:

Следующий шаг:  создание и компиляция шейдера. Для этого нам потребуются следующие OpenGL функции:  glCreateShader ,  glShaderSource ,  glCompileShader ,  glGetShaderiv (для получения информации о результате компиляции) , glDeleteShader и  glGetShaderInfoLog  (для получения ошибки, если скомпилировать шейдер не удалось). Но беда вся в том, что у нас нет этих функций(. И прежде чем ими воспользоваться, нужно найти указатели на них (помним, мы все еще не используем OpenGL загрузчик).

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

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

Создание объекта шейдерной программы выглядит похоже на создание объекта шейдера. Для реализации этой функции, в свою очередь, нам потребуются следующие OpenGL функции:  glCreateProgram ,  glDeleteProgram ,  glAttachShader ,  glLinkProgramARB ,  glGetProgramiv ,  glGetProgramInfoLog и  glDetachShader . Уже знакомым кодом получаем ссылки и на эти функции:

И реализация функции  CreateShader :

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

Наконец-то мы подошли к долгожданной отрисовке треугольника.

Для отрисовки треугольника нам нужно указать три вершины (точки). Видеокарта не может напрямую работать с данными в оперативной памяти, по этому, чтобы, отрисовать треугольник, нам нужно загрузить необходимые данные (в данном случае три вершины) в видеопамять. В этом нелегком деле нам поможет Vertext Buffer Object (VBO) — это объект вершинного буфера в который мы можем загружать необходимую для отрисовки информацию. С каждой вершиной ассоциируются некоторые данные, такие как положение вершины, цвет, нормаль, текстурные координаты и любые другие данные необходимые для отрисовки. Загрузка данных из оперативной памяти в видеопамять довольно затратная операция и обычно должна осуществлятся как можно реже и как можно большим размером. Т.е. выгоднее один раз загрузить больше данных, чем несколько раз грузить по чуть-чуть. По этому и используются буферы.

Для того, чтобы воспользоваться данными из буфера нам нужно создать Vertext Array Object (VAO) . Так как в буфере могут лежать данные сразу для нескольких объектов (например для пары треугольников) и формат данных может отличаться, в зависимости от наших требований, то, чтобы объяснить OpenGL какую часть VBO буфера использовать и как интерпретировать данные буфера VAO объекты и используются. Грубо говоря VAO это «массив» вершинных атрибутов (т.е. некоторых данных, таких как цвет или положение вершины, ассоциированных с вершиной).

Алгоритм инициализации VBO и VAO для последующего использования при отрисовке:

  • запросить объект VBO буфера;
  • сделать буфер текущим;
  • загрузить данные в буфер;
  • деактивировать буфер;
  • запросить объект VAO;
  • сделать объект VAO текущим.

Для работы с VBO и VAO нам потребуются следующие функции:  glGenBuffers ,  glBindBuffer ,  glBufferData , glDeleteBuffers ,   glGenVertexArrays ,  glBindVertexArray ,  glVertexAttribPointer , glDeleteVertexArrays . По этому сразу добавляем подключение всех выше перечисленных функций.

И инициализация VBO и VAO:

Для отрисовки треугольника нам потребуются следующие функции:  glUseProgram ,  glEnableVertexAttribArray ,  glDisableVertexAttribArray  и  glDrawArrays . Функция  glDrawArrays уже объявлена в заголовочном файле  gl.h  и дополнительного поиска не требует. Находим остальные функции как обычно:

Процесс отрисовки треугольника:

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

Заключение

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

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

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