Опыт изучения OpenGL — Часть 1 — Введение

С детства мечтал писать компьютерные игры, и вот, года три назад решил рискнуть — поизучать OpenGL. Почему взялся изучать не игровой движок, а низкоуровневый API (API — Application Programming Interface)? Потому что люблю изучать все с как можно более низкого уровня, чтобы разобраться как оно работает (например, изучение языка ассемблера Intel очень помогло моему пониманию языков Си и C++). А почему OpenGL, а не DirectX? — Просто по OpenGL я смог найти больше литературы, чем по DirectX. Изучать OpenGL — задачка очень непростая, а теперь появился еще более низкоуровневый API — Vulkan, пытаюсь и про него читать понемногу. Но сначала хочу поделиться с вами своим опытом изучения OpenGL. Штурмовал я его раза три c перерывами в год, и на третий раз наконец достиг чего-то более сложного, чем рисование разноцветных треугольников — объемные движущиеся объекты, движущаяся камера, освещение, текстуры и тени — уже, на мой взгляд, неплохо (рисунок 1).

Рисунок 1 — Солнце, Земля и Луна и Млечный Путь на заднем плане, отрисованные при помощи OpenGL. Луна вращается вокруг Земли, а Земля и Солнце — вокруг своих осей. Солнце является единственным точечным источником света. Используется модель освещения Phong lighting model плюс shadow mapping. Всё это отрисовывается программой, которую я написал сам, используя только OpenGL, стандартную библиотеку языка C++ (STL) и WinAPI.

Литература

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

  • Jason McKesson — Learning Modern 3D Graphics Programming
  • David Wolf — OpenGL 4 Shading Language Cookbook (2nd Edition)
  • Jason Gregory — Game Engine Architecture (2nd Edition)
  • Edward Angel, Dave Shreiner — Interactive Computer Graphics (6th Edition)
  • Dave Shreiner, Graham Sellers, John Kessenich, Bill Licea-Kane — OpenGL Programming Guide (8th Edition)
  • John Kessenich, Graham Sellers, Dave Shreiner — OpenGL Programming Guide (9th Edition)
  • Graham Sellers — OpenGL SuperBible (7th Edition)
  • OpenGL 4.5 API and Shading Language Reference Pages

Книги по C++ (все перечисленные, на мой взгляд, одинаково полезны):

  • Бьярн Страуструп — Программирование. Принципы и практика с использованием C++
  • Bjarne Stroustrup. The C++ Programming Language. Fourth Edition.
  • Скотт Мейерс — Эффективное использование C++
  • Скотт Мейерс — Наиболее эффективное использование C++
  • Scott Meyers — Effective Modern C++

OpenGL — это C API, т. е. пользоваться им проще всего в программах на языках C/C++. Авторы книг ставят перед собой цель продемонстрировать непосредственное использование этого API, поэтому в книгах по OpenGL весь код как правило написан на языке Си, причем он длинный, трудночитаемый и трудноизменяемый. Я хотел сравнительно легко и быстро писать графические программы, поэтому должен был написать свой API поверх OpenGL, и конечно, на C++ (далее в тексте я, говоря о своем проекте, могу называть его по-разному: API, движок, фреймворк). Когда создаешь свой API, хорошо бывает применить top-down approach, т. е. проектирование «сверху вниз». Я этот подход описываю так: представьте, как должна выглядеть ваша программа, когда ваш API уже полностью написан. Запишите эту программу — в ней будут конечно же создаваться объекты, вызываться методы классов… Вот, теперь вы знаете какие классы вам нужны и какие у них должны быть методы — напишите же их!

В написании собственного API мне очень помогла великолепная книжка [Gregory]. Что касается книг по API OpenGL… Официальное руководство по программированию (т. н. красная книга) [OpenGL Programming Guide], как мне кажется, не очень годится для изучения, оно скорее напоминает справочник, и читать его ужасно скучно. К счастью я нашел минимум две книги «с человеческим лицом». [McKesson] хорошо объясняет основы компьютерной графики (графический конвейер, камера, перспективная проекция, матрицы, шейдеры, освещение, текстуры) — эту книгу приятно и интересно читать (правда в ней используется OpenGL 3.3, и некоторые функции, используемые в книге, можно уже заменить на более новые из OpenGL 4). Книжка [Wolf] — это собрание практических примеров реализации той или иной техники создания визуальных эффектов (освещение, текстурирование, тени, blending и пр.), написанное очень понятным языком.

Надо заметить, что практически во всех книгах авторы пользуются вспомогательными библиотеками для работы с оконным интерфейсом операционной системы — чаще всего это библиотеки FreeGLUT или GLFW. Для векторных вычислений используют библиотеку GLM. Я же принципиально не использовал никакие библиотеки (библиотека GLEW не в счет — она лишь загружает функции OpenGL), поскольку хотел освоить буквально все аспекты программирования графических приложений с нуля. Поэтому в дальнейших заметках речь пойдет, помимо прочего, и о создании окон при помощи WinAPI, и о векторных вычислениях.

Создание проекта

Я создал проект в MS Visual Studio 2015, назвал его RenderingEngine. Проект размещен в открытом репозитории на сайте BitBucket (о том как клонировать репозиторий и прочее см. заметку про Git). В этой и последующих заметках я буду приводить фрагменты исходного кода, которые существенны для обсуждения. Привести весь исходный код, прокомментировав каждую строчку невозможно, да и смысла нет. Если вам понадобятся подробности кода, которых нет в заметках, загляните в репозиторий.

Прежде всего я хотел «организовать рабочее пространство» — разложить всё по папочкам. Почитал некоторые рекомендации в интернете (StackOverflow — VC2010 C++ — organizing source files) и решил создать четыре папки:

include для хранения заголовочных файлов сторонних библиотек
lib для хранения двоичных файлов сторонних библиотек
src для хранения моего собственного кода (как заголовочных файлов, так и файлов исходного кода)
build для хранения двоичного файла моей программы

Позднее я добавил еще две папки:

shaders для хранения шейдеров
textures для хранения текстур

Чтобы изменить выходную папку, в которую помещается построенный исполняемый файл программы (по-умолчанию он помещается в папку $(SolutionDir)\$(Configuration)), я должен был поменять некоторые свойства проекта (через меню Project->Properties в Visual Studio):

Project -> Properties -> General -> Output Directory
    $(SolutionDir)\build\$(Configuration)\

Project -> Properties -> Debugging -> Working Directory
    $(OutDir)

$(SolutionDir), $(Configuration), $(OutDir) и прочее — это так называемые макросы для команд и свойств построения. При построении проекта они подменяются своими значениям. Например, $(Configuration) заменяется на Debug или Release.

Также надо было добавить папку include в пути поиска заголовочных файлов

Project -> Properties -> C/C++ -> General -> Additional Include Directories
    $(SolutionDir)\include;%(AdditionalIncludeDirectories)

Пространства имен

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

opengl Несмотря на то, что API OpenGL основан на языке C, он, тем не менее — объектно-ориентированный в том же смысле, в каком объектно-ориентированным является и WinAPI. Сплошь и рядом функции OpenGL создают какие-то объекты и возвращают их так называемые дескрипторы (или хэндлы) — числовые идентификаторы, при помощи которых программа может на эти объекты ссылаться. В общем, API OpenGL легко и естественно можно перевести в объектно-ориентированный сиплюсплюсный вид, что я и сделал, создав такие классы как Shader, Buffer, FramebufferObject, Renderbuffer, ProgramPipeline и другие и поместив их в пространство имен opengl.
win В это пространство имен я поместил все классы и функции, которые используют WinAPI и поэтому являются специфичными для ОС Windows. Например, классы Window, HighResolutionTimer, winapi_error и другие.
engine В это пространство имен я помещал классы, которые воспринимаются мной как «высокий уровень абстракции». Например классы Camera, Mesh, Model3D, Skybox, ShadowMap и другие. Эти классы зависят от классов в пространстве имен opengl.
util В это пространство имен я помещал классы и функции, которые выполняли роль полезных в хозяйстве инструментов. Типичный пример — функции из пространства имен util::Requires, которые например проверяют корректность аргументов, переданных в функцию и другие условия. Также в пространство имен util попали функции file::ReadAllText, file::getFileExtension, класс Event и пр.
vmath Сюда попали классы Vector и Matrix и всевозможные функции для работы с матрицами и векторами. Эти классы зависят от платформы Intel, поскольку используют Streaming SIMD Extensions (SSE), впрочем использование SSE можно отключить при помощи условной компиляции.

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

Загрузка функций OpenGL

Итак, я хочу использовать в своей программе некий API на языке C. Обычно это значит вот что. API представляет собой набор функций. Прототипы (сигнатуры) этих функций находятся в некоем заголовочном файле (или файлах), а сами функции находятся в двоичном файле библиотеки (в ОС Windows такие файлы имеют расширение .lib или .dll). Также существует документация, в которой написано, что каждая функция делает и как вообще эти функции следует использовать.

В OpenGL это не совсем так. В каждой ОС существует библиотека, которая содержит ограниченный набор OpenGL’евских функций. В Windows это библиотека opengl32.dll, которая содержит набор функций, соответствующих устаревшей сто лет назад версии OpenGL 1.1. С тех пор огромное количество новых функций было добавлено в API, к тому же многие функции теперь объявлены устаревшими (т. е. ими буквально нельзя пользоваться). Предполагается загружать новые функции динамически, т. е. получать адреса функций во время выполнения программы. Для этого в OpenGL всегда был предусмотрен так называемый OpenGL extension mechanism — механизм, позволяющий производителям видеокарт добавлять в API новые функции, а пользователям (программистам) — эти функции вызывать. Но если эти функции не находятся в opengl32.dll, то где они тогда лежат? Ответ: в библиотеке, которая входит в состав ПО, которое поставляется вместе с вашей видеокартой. Например для видеокарт NVIDIA файл OpenGL’евской библиотеки может называться как-то так: nvogl32.dll. Однако программист, разумеется, не должен компоновать свою программу с библиотекой производителя видеокарты (в противном случае его программа была бы плохо переносимой), вместо этого программа запрашивает указатели на функции API во время выполнения. Каким образом это делается, подробно описано в статье Load OpenGL Functions. Скажу только, что в Windows для получения указателя на какую либо функцию OpenGL используется функция под названием wglGetProcAddress (заголовочный файл wingdi.h, библиотека opengl32.dll; все функции, начинающиеся с wgl, относятся к ОС Windows). Функция wglGetProcAddress принимает в качестве параметра имя искомой функции. Что происходит у wglGetProcAddress «под капотом» для меня — тайна, покрытая мраком. Загружает ли она в память приложения библиотеку от производителя видеокарты и если да, то как она ее находит? Почему для ее вызова необходимо создать контекст OpenGL (об этом ниже)? Впрочем это не столь важно, так как на самом деле рекомендуется перепоручать загрузку всех функций OpenGL специальным библиотекам, которые называются OpenGL Loading Libraries. Одной из таких библиотек является OpenGL Extension Wrangler Library (GLEW), которую я и использовал в своем проекте.

Библиотека OpenGL Extension Wrangler Library (GLEW)

Библиотека GLEW предназначена для загрузки функций API OpenGL. Что это значит? Предположим, вам надо вызвать какую-нибудь OpenGL’евскую функцию, например glAttachShader. Чтобы вызвать функцию, вам нужен ее адрес. Адрес функции можно получить при помощи wglGetProcAddress. Поскольку вы собираетесь вызывать функцию glAttachShader много раз в разных местах программы, ее адрес надо сохранить в некой глобальной переменной. Эта переменная должна иметь тип указателя на функцию, сигнатура которой совпадает с сигнатурой функции glAttachShader, описанной в спецификации OpenGL. И таких функций как glAttachShader — сотни. Для каждой из них в библиотеке GLEW имеется глобальная переменная, которая хранит адрес функции. А значения этим переменным присваиваются функцией glewInit. Ниже показан фрагмент заголовочного файла glew.h, в котором объявлены прототип функции glAttachShader и указатель на нее. Я снабдил этот фрагмент своими комментариями для большей понимабельности.

// glew.h

// The most important function in GLEW library which fills a bunch of global function pointers
// with the adresses of OpenGL functions.
GLEWAPI GLenum GLEWAPIENTRY glewInit (void);

// No idea what this is used for :)
#ifndef GLEW_GET_FUN
#define GLEW_GET_FUN(x) x
#endif

// Pointer-to-function type definition.
typedef void (GLAPIENTRY * PFNGLATTACHSHADERPROC) (GLuint program, GLuint shader);

// The pointer to function glAttachShader.
GLEW_FUN_EXPORT PFNGLATTACHSHADERPROC __glewAttachShader;

// Macrodefinition which translates all references to glAttachShader to __glewAttachShader.
#define glAttachShader GLEW_GET_FUN(__glewAttachShader)

Итак, чтобы воспользоваться библиотекой GLEW, надо

  1. Скачать библиотеку GLEW с оф. сайта. Из скачанного архива мне нужны только четыре файла: glew.h, wglew.h (этот заголовочный файл нужен, если программа предназначается для ОС Windows), glew32.lib и glew32.dll, которые я распихиваю в папки своего проекта. glew.h, wglew.h — в папку \include\GL, glew32.lib — в папку \lib\GLEW, glew32.dll — в папки \build\Debug и \build\Release. В своем проекте я использую динамическую компоновку с библиотекой glew32.dll, однако можно использовать и статическую — тогда файл glew32.dll не нужен, а вместо файла glew32.lib надо взять файл glew32s.lib. Включение заголовочных файлов GLEW и подключение библиотек glew32.lib и opengl32.lib я поместил в файл GLEWHeaders.h:

    // GLEWHeaders.h
    #pragma once

    // Link to opengl32.lib
    #pragma comment(lib, "opengl32.lib")

    // Link to glew32.lib
    #pragma comment(lib, "glew32.lib")

    // You can also statically link GLEW library as follows:
    // #pragma comment(lib, "glew32s.lib")
    // #define GLEW_STATIC

    #include "GL/glew.h"
    #include "GL/wglew.h"
  2. В свойствах проекта Visual Studio указать путь к папке \lib\GLEW как дополнительный путь для поиска библиотек:

    Project -> Properties -> Linker -> General -> Additional Library Directories
        $(SolutionDir)\lib\GLEW;%(AdditionalLibraryDirectories)
  3. В программе вызвать функцию glewInit.

С вызовом glewInit связаны некоторые трудности. Оказывается, для того, чтобы вызвать glewInit, надо сначала создать так называемый OpenGL rendering context (далее — rendering context или контекст рисования). Функция wglGetProcAddress тоже не будет работать без созданного rendering context’а. Что же это такое — контекст рисования? Попробую дать определение.

  • Rendering context представляет собой некий объект, обладающий состоянием. Это состояние влияет на результат (или управляет результатом) работы функций OpenGL. В частности, состояние, в котором находится rendering context, определяет изображение, которое формируется на экране компьютера. Вызовы функций OpenGL могут изменять состояние rendering context’а. Пользовательская программа может взаимодействовать с rendering context’ом только при помощи функций OpenGL.
  • Могу предположить, что rendering context хранится в памяти драйвера видеокарты.
  • Пользовательская программа может создавать (функции wglCreateContext и wglCreateContextAttribsARB) и уничтожать (функция wglDeleteContext) rendering context. При создании контекста программа указывает, каким требованиям он должен удовлетворять (например, поддерживать определенную версию OpenGL).
  • Rendering context как правило существует в приложении в единственном экземпляре. Однако можно создать в одной программе несколько rendering context’ов. Если в программе есть несколько rendering context’ов, то в конкретный момент времени только один из них может быть активным (или текущим). Активный rendering context — это тот, который в данный момент влияет на результат работы функций OpenGL (т. е. например на отрисовку изображения на экране) и на состояние которого влияют вызовы функций OpenGL. Программа может переключаться между несколькими rendering context’ами (делать текущим то один, то другой контекст — см. функцию wglMakeCurrent). Понятие «активный контекст» принадлежит потоку управления, т. е. каждый поток управления может иметь свой активный контекст OpenGL. Заметим, что нежелательно, чтобы два разных потока управления имели одинаковый активный контекст OpenGL.
  • В Windows rendering context связан с так называемым контекстом устройства (device context — даже не хочу разбираться, что это такое, можете почитать статью Device Contexts) или проще говоря — с окном. Без окна нельзя создать контекст рисования. Соответственно, в этом окне и будет отрисовываться изображение, формируемое программой путем вызова различных функции OpenGL. При этом во время выполнения программы можно связывать любой контекст рисования с любым контекстом устройства. Можно даже связать один контекст рисования с несколькими контекстами устройств (но не наоборот) — см. функцию wglMakeCurrent. Тогда вы получите одно и то же изображение, отрисованное в нескольких окнах.
  • Без контекста рисования нельзя ничего нарисовать на экране компьютера.

Почему надо непременно создать контекст рисования, чтобы вызвать glewInit? Возможно это связано с тем, что в Windows значения указателей на функции, которые возвращает wglGetProcAddress, зависят от текущего контекста рисования… О’кей, давайте создадим этот контекст, тем более, что для рисования все равно нужен контекст рисования. Но оказывается, что контекст контексту рознь. По-настоящему работающий контекст рисования (при помощи которого можно что-то нарисовать) можно создать только при помощи функций wglChoosePixelFormatARB и wglCreateContextAttribsARB. Но адресов этих функций у нас нет, а чтобы их получить, нам надо либо вызвать glewInit либо вызвать wglGetProcAddress, но ни то, ни другое нельзя сделать без контекста рисования. Замкнутый круг? К счастью существует старинная функция для создания контекста рисования wglCreateContext, которая экспортируется непосредственно библиотекой opengl32.dll и поэтому ее адрес у нас есть изначально. Хотя такой контекст рисования будет поддерживать только версию OpenGL 1.1, и рисовать что-либо с его помощью нецелесообразно, он позволит нам вызвать glewInit. Затем мы его удалим и создадим новый нормальный контекст. Всю работу по инициализации GLEW я поместил в функцию Initialize_GLEW_Library в файлах GlewInitializer.h и GlewInitializer.cpp (см. листинг ниже).
Итак, алгоритм инициализации библиотеки GLEW следующий:

  • Создать окно. Окно нужно, так как без него нельзя создать контекст рисования. Кстати, созданное окно мы не отображаем на экране.
  • Создать контекст рисования при помощи функции wglCreateContext. Сделать его активным при помощи функции wglMakeCurrent.
  • Вызвать функцию glewInit.
  • Уничтожить окно. Для этого надо послать ему сообщение WM_DESTROY при помощи функции DestroyWindow. Это сообщение надо еще и обработать в цикле обработки сообщений, именно поэтому, несмотря на то, что окно мы нигде не отображаем, цикл обработки сообщений запускать все же приходится.
  • Уничтожить созданный ранее контекст рисования (поскольку он больше не нужен) при помощи функции wglDeleteContext.
// GlewInitializer.h
#pragma once

#include "GLEWHeaders.h"

namespace opengl
{
    // Initializes GLEW library
    void Initialize_GLEW_Library();
}
// GlewInitializer.cpp

#include <Windows.h>
#include "GlewInitializer.h"
#include "winapi_error.h"
#include "GlException.h"

namespace opengl
{
    LRESULT CALLBACK FalseWndProc(
        HWND hWnd,
        UINT message,
        WPARAM wParam,
        LPARAM lParam)
    {
        switch (message)
        {
            case WM_DESTROY:
            {
                PostQuitMessage(0);
                break;
            }
            default:
            {
                return DefWindowProc(hWnd, message, wParam, lParam);
            }
        }

        return 0;
    }

    HGLRC CreateFalseRenderingContext(HDC hDC)
    {
        PIXELFORMATDESCRIPTOR pfd;

        // Choose a stub pixel format in order to get access to wgl functions.
        ::SetPixelFormat(
            hDC,   // Device context.
            1,     // Index that identifies the pixel format to set. The various pixel formats supported by a device context are identified by one-based indexes.
            &pfd); // [out] Pointer to a PIXELFORMATDESCRIPTOR structure that contains the logical pixel format specification.

        // Create a fiction OpenGL rendering context.
        HGLRC hGLRC = wglCreateContext(hDC);

        // Throw exception if failed to create OpenGL rendering context.
        if (hGLRC == NULL)
            throw win::make_winapi_error("opengl::CreateFalseRenderingContext() -> wglCreateContext()");

        // Make just created OpenGL rendering context current.
        if (!wglMakeCurrent(hDC, hGLRC))
            throw win::make_winapi_error("opengl::CreateFalseRenderingContext() -> wglMakeCurrent()");

        return hGLRC;
    }

    void Initialize_GLEW_Library()
    {
        static bool s_IsInitialized = false;

        if (s_IsInitialized)
            return;

        s_IsInitialized = true;

        const char* wndClassName = "FalseWindow";
        HINSTANCE hInst = GetModuleHandle(NULL); // get application handle

        // Create a struct describing window class.
        WNDCLASSEX wcex;
        wcex.cbSize         = sizeof(WNDCLASSEX);                                 // struct size
        wcex.style          = CS_HREDRAW | CS_VREDRAW | CS_OWNDC;                 // window style
        wcex.lpfnWndProc    = FalseWndProc;                                       // pointer to window function WndProc
        wcex.cbClsExtra     = 0;                                                  // shared memory
        wcex.cbWndExtra     = 0;                                                  // number of additional bytes
        wcex.hInstance      = hInst;                                              // current application's handle
        wcex.hIcon          = LoadIcon(hInst, MAKEINTRESOURCE(IDI_APPLICATION));  // icon handle
        wcex.hCursor        = LoadCursor(NULL, IDC_CROSS);                        // cursor handle
        wcex.hbrBackground  = (HBRUSH)(COLOR_MENU+1);                             // background brush's handle
        wcex.lpszMenuName   = NULL;                                               // pointer to a string - menu name
        wcex.lpszClassName  = wndClassName;                                       // pointer to a string - window class name
        wcex.hIconSm        = LoadIcon(hInst, MAKEINTRESOURCE(IDI_APPLICATION));  // small icon's handle

        // Register window class for consequtive calls of CreateWindow or CreateWindowEx.
        if (!RegisterClassEx(&wcex))
            throw win::make_winapi_error("opengl::Initialize_GLEW_Library() -> RegisterClassEx()");

        // Create a FICTION window based on the previously registered window class.
        HWND hWnd = CreateWindow(wndClassName,                  // window class name
                                 wndClassName,                  // window title
                                 WS_OVERLAPPEDWINDOW,           // window type
                                 CW_USEDEFAULT, CW_USEDEFAULT,  // window's start position (x, y)
                                 100,                           // window's  width in pixels
                                 100,                           // window's  height in pixels
                                 NULL,                          // parent window
                                 NULL,                          // menu handle
                                 hInst,                         // application handle
                                 NULL);                         // pointer to an object passed to the window with CREATESTRUCT struct (field lpCreateParams), pointer to which is contained in lParam parameter of WM_CREATE message

        // Throw exception if CreateWindow() failed.
        if (!hWnd)
            throw win::make_winapi_error("opengl::Initialize_GLEW_Library() -> CreateWindow()");

        // Get device context for the window.
        HDC hDC = GetDC(hWnd);

        // Create a fiction rendering context.
        HGLRC tempOpenGLContext = CreateFalseRenderingContext(hDC);

        // Initialize GLEW (is possible only if an OpenGL rendering context is created).
        if(glewInit() != GLEW_OK)
            throw GlException("opengl::Initialize_GLEW_Library() -> glewInit()");

        DestroyWindow(hWnd);

        MSG msg = { 0 };
        while(msg.message != WM_QUIT)
        {
            while(GetMessage(&msg, NULL, 0, 0))
            {
                TranslateMessage(&msg);
                DispatchMessage(&msg);
            }
        }

        wglMakeCurrent(NULL, NULL);          // remove the temporary context from being active
        wglDeleteContext(tempOpenGLContext); // delete the temporary OpenGL context
    }
}

В приведенном коде используются классы win::winapi_error и opengl::GlException — это всего-навсего классы исключений для ошибок, связанных соответственно с WinAPI и OpenGL, приводить их код здесь не буду.

На этом все. В следующей заметке я расскажу о том, как создать окно (рисовать-то мы будем именно в окне).

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

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