Опыт изучения OpenGL — Часть 2 — Окно

В прошлой заметке я начал рассказывать про свой опыт изучения OpenGL и остановился на том, что проинициализировал библиотеку GLEW, которая загружает все функции API OpenGL. Чтобы начать рисовать что-либо при помощи OpenGL, нам нужно то, на чем можно рисовать, а именно — окно. Ему и посвящена настоящая заметка. Работа окна имеет непосредственное отношение к используемой нами операционной системе, в моем случае — Windows. Поэтому в коде активно используется WinAPI. Исходный код проекта по изучению OpenGL находится в открытом репозитории.

Литература

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

  • Чарльз Петцольд — Программирование для Windows 95
  • Джеффри Рихтер, Кристоф Назар — Windows Via C/C++

Функция main

В программах на C/C++ всё, как известно, начинается с функции main. В Windows при компоновке программы надо указывать т. н. подсистему, коей для пользовательских приложений может быть CONSOLE, WINDOWS или POSIX (последний вариант мне пока использовать не приходилось) — эта информация нужна ОС Windows при запуске приложения. Если подсистема — CONSOLE, то создается всем знакомое консольное окошко, если WINDOWS — консольного окошка не создается. Можно не задавать подсистему явно — компоновщик в состоянии сам ее определить: если у вас в программе есть функция main, то подсистема — CONSOLE, если функция называется не main, а WinMain, то подсистема — WINDOWS. Создавать или не создавать консольное окошко в графическом приложении? В моем случае приложение рисует в графическом окне 3d-графику, и консольное окно удобно держать в программе для вывода диагностических сообщений. Тот факт, что подсистема в программе CONSOLE, никак не мешает насоздавать сколько угодно графических окон.
Что в принципе должна делать графическая программа в ОС Windows? Она должна создать по крайней мере одно окно и выполнять бесконечный цикл обработки сообщений. С точки зрения top-down approach (если мы представим, что у нас уже есть некий класс Window) это может выглядеть примерно так:

void main()
{
    Window window;
    window.Show();

    MSG msg{ 0 };
    while (GetMessage(&msg, NULL, 0, 0))
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
}

Функция GetMessage извлекает из очереди очередное сообщение и помещает его в структуру MSG msg. GetMessage возвращает булевское значение TRUE, если получено любое сообщение кроме WM_QUIT, которое сигнализирует о закрытии программы. Функция TranslateMessage преобразует сообщение от клавиатуры из одного формата в другой — содержащий код нажатого символа на клавиатуре в формате UTF-16 — и посылает это преобразованное сообщение потоку (подробности см. в MSDN). Насколько я понимаю, функцию TranslateMessage можно не вызывать, если ваша программа не работает с текстом, вводимым с клавиатуры. Функция DispatchMessage вызывает оконную процедуру, которая обрабатывает сообщение. Оконная процедура — это процедура, которая должна обрабатывать конкретные сообщения. Сообщение она принимает в качестве параметра. Адрес оконной процедуры указывается в структуре WNDCLASSEX при регистрации класса окна (об этом см. ниже), поэтому оконная процедура принадлежит целому классу окон, и если окон на основе этого класса создано несколько, то она будет вызываться для обработки сообщений для всех этих окон.

Краткая теория окон

В Windows работа графических приложений основана на системе передачи сообщений. Действия пользователя с окном, такие как щелчки мыши, нажатия клавиатуры, перетаскивание и другие называются событиями. При возникновении события ОС посылает окну приложения сообщение (message). Это означает, что ОС помещает небольшой кусочек данных (сообщение), в очередь сообщений (message queue). Этот кусочек данных содержит информацию о событии (например, если пользователь щелкнул мышью, то сообщение содержит информацию о том, в какой точке окна он щелкнул и на какую именно кнопку мыши нажал). Очередь сообщений принадлежит тому потоку управления программы, который создал окно. Если поток не создавал окон, то у него нет очереди сообщений, так как Windows создает для потока очередь сообщений при создании этим потоком хотя бы одного окна. Поток может извлечь сообщение из очереди (см. функции GetMessage и PeekMessage) и как-то на него отреагировать (т. е. обработать). Обычно в программе принято иметь только один поток, который создает окна и обрабатывает сообщения — его называют потоком обработки сообщений. Этот поток извлекает сообщения из очереди в цикле, который называют циклом обработки сообщений (message loop). Дополнительную информацию см. в статье Using Messages and Message Queues.

Класс win::Window

Понятие «окно» прекрасно соответствует понятию «объект» объектно-ориентированного программирования, поэтому для окна можно запросто написать класс на C++. Окно можно создать (конструктор класса), взаимодействовать с ним (функции-члены класса) и в конце — уничтожить (деструктор класса). Пойдем по-порядку.

Конструктор и деструктор

Как создать окно в Windows при помощи WinAPI? По моему скромному мнению, процесс несколько забюрократизирован.

  1. Создать и зарегистрировать т. н. класс окна (функция RegisterClassEx). Здесь под классом окна понимается структура WNDCLASSEX — не путать с классом в языке C++. В структуре WNDCLASSEX указывается ряд параметров окна, в том числе адрес оконной процедуры WndProc — той процедуры, которую вызывает функция DispatchMessage (см. выше). На основе класса окна можно насоздавать много окон со схожими параметрами (очевидно для этого понятие «класс окна» и было введено).
  2. Создать окно на основе класса окна (функция CreateWindow), указав еще ряд параметров, таких как текст заголовка и размеры. Эти параметры, очевидно, считаются уникальными для каждого окна, поэтому они не входят в структуру класса окна WNDCLASSEX.

WinAPI так же как и OpenGL является объектно-ориентированным (несмотря на то, что это C-API). Например, окно — это объект. Каждый объект в WinAPI идентифицируется так называемым дескриптором (он же — описатель, он же — хэндл). Этот дескриптор возвращает функция WinAPI, которая создает объект, например CreateWindow. Дескриптор следует сохранить, чтобы использовать его для взаимодействия с объектом. При создании окна его дескриптор (переменная типа HWND) логично будет сохранить в поле класса Window.
Созданием окна должен заниматься конструктор класса. Тут возникает пара трудностей:

  1. Создавать и регистрировать класс окна нужно только один раз за время работы программы, а не каждый раз при создании окна. В C# подобные проблемы решаются при помощи статического конструктора, который вызывается при первом обращении к классу. В C++ нет понятия статического конструктора, но его можно легко реализовать — создать статическую функцию и вызывать ее в каждом конструкторе класса. Функция может определить, вызывали ли ее хотя бы раз при помощи статической переменной. В моем коде эта функция называется InitWndClass.
  2. В конечном счете обрабатывать сообщение, посланное окну, по идее должна экземплярная функция-член класса Window. Но сообщение передается оконной процедуре, а оконная процедура принадлежит не конкретному окну, а всему классу окон в целом. Разумеется, оконная процедура не может быть экземплярной функцией-членом класса. Идентифицировать окно, которому адресовано сообщение, можно по дескриптору окна, который содержится в структуре MSG. Таким образом, оконная процедура должна по дескриптору найти экземпляр окна и вызвать его функцию-член, которая обработает сообщение. Для этого придется завести некий глобальный массив, куда помещать ссылки на экземпляры окон при их создании. Тогда оконная процедура сможет найти нужное окно в этом массиве. В моей программе этим массивом является ассоциативный массив std::map s_RegisteredWindows.

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

class Window
{
    private:
        static WNDCLASSEX s_WndClassEx;                     // WinAPI window class structure
        static std::map<HWND, Window*> s_RegisteredWindows; // collection of existing windows
        HWND m_hWnd;                                        // window descriptor

    public:
        // Constructor
        Window(const std::string& title,
               int width,
               int height)
        {
            InitWndClass();

            m_hWnd
                = ::CreateWindow(
                    s_WndClassEx.lpszClassName,   // window class name
                    title.c_str(),                // window title
                    WS_OVERLAPPEDWINDOW,          // window type
                    CW_USEDEFAULT, CW_USEDEFAULT, // starting window location in pixels (x, y)
                    width,                        // window width in pixels
                    height,                       // window height in pixels
                    NULL,                         // parent window descriptor
                    NULL,                         // menu descriptor
                    GetModuleHandle(NULL),        // application descriptor
                    NULL);                        // pointer to a value passed along with WM_CREATE message through ((CREATESTRUCT*)lParam)->lpCreateParams

            // Place window descriptor to the registered windows collection.
            s_RegisteredWindows[m_hWnd] = this;
        }

        // Destructor
        Window::~Window()
        {
            ::CloseWindow(m_hWnd);
            ::DestroyWindow(m_hWnd);
        }

    private:
        static void InitWndClass()
        {
            // flag showing whether the window class has been initialized
            static bool s_Initialized = false;

            if (!s_Initialized)
            {
                s_Initialized = true;

                HINSTANCE hInst = GetModuleHandle(NULL);

                s_WndClassEx.cbSize = sizeof(WNDCLASSEX);                                 // structure size
                s_WndClassEx.style = CS_HREDRAW | CS_VREDRAW | CS_OWNDC;                  // window style
                s_WndClassEx.lpfnWndProc = Window::WndProc;                               // pointer to window procedure WndProc
                s_WndClassEx.cbClsExtra = 0;                                              // shared memory
                s_WndClassEx.cbWndExtra = 0;                                              // number of additional bytes
                s_WndClassEx.hInstance = hInst;                                           // application handle
                s_WndClassEx.hIcon = LoadIcon(hInst, MAKEINTRESOURCE(IDI_APPLICATION));   // icon descriptor
                s_WndClassEx.hCursor = LoadCursor(NULL, IDC_ARROW);                       // cursor descriptor
                s_WndClassEx.hbrBackground = reinterpret_cast<HBRUSH>(COLOR_MENU + 1);    // background brush descriptor
                s_WndClassEx.lpszMenuName = NULL;                                         // menu name
                s_WndClassEx.lpszClassName = "win::Window";                               // window class name
                s_WndClassEx.hIconSm = LoadIcon(hInst, MAKEINTRESOURCE(IDI_APPLICATION)); // small icon descriptor

                // register window class
                RegisterClassEx(&s_WndClassEx);
            }
        }

        // The so-called window procedure handles all the messages received
        // by all windows of a particular window class
        static LRESULT CALLBACK WndProc(HWND hWnd,
                                        UINT message,
                                        WPARAM wParam,
                                        LPARAM lParam)
        {
            Window* pWindow = NULL;
            try
            {
                pWindow = s_RegisteredWindows.at(hWnd);
            }
            catch (std::out_of_range&)
            {
                return ::DefWindowProc(hWnd, message, wParam, lParam);
            }

            return pWindow->WindowProcedure(message, wParam, lParam);
        }

        // This procedure handles all the messages received by the window
        LRESULT WindowProcedure(UINT message,
                                WPARAM wParam,
                                LPARAM lParam)
        {
            switch (message)
            {
                case WM_PAINT:       { ... break; }
                case WM_MOUSEMOVE:   { ... break; }
                case WM_LBUTTONDOWN: { ... break; }
                case WM_LBUTTONUP:   { ... break; }
                case WM_TIMER:       { ... break; }
                case WM_KEYDOWN:     { ... break; }
                case WM_KEYUP:       { ... break; }
                case WM_COMMAND:     { ... break; }
                case WM_SIZE:        { ... break; }
                case WM_DESTROY:
                {
                    // remove window descriptor from the registered windows collection
                    auto elems_erased = s_RegisteredWindows.erase(m_hWnd);
                    assert(elems_erased == 1);

                    // if this was the last existing window, quit
                    if(s_RegisteredWindows.size() == 0)
                        PostQuitMessage(0);

                    break;
                }
                default:
                {
                    return ::DefWindowProc(m_hWnd, message, wParam, lParam);
                }
            }

            return 0;
        }
}

Обращу ваше внимание на строчку s_WndClassEx.style = CS_HREDRAW | CS_VREDRAW | CS_OWNDC;. CS_HREDRAW и CS_VREDRAW означают, что при изменении размеров окна ему будет посылаться сообщение WM_PAINT (сигнал о необходимости перерисовки), а CS_OWNDC означает, что для она будет создан постоянный контекст устройства — это важно для работы OpenGL (подробности см. в статьях Creating an OpenGL Context (WGL) и What does the CS_OWNDC class style do?.

Функции-члены

Программируя на C#, я давно привык к таким понятиям C# как свойства и события, которые отсутствуют в стандартном C++. Поскольку свойство — это ничто иное как пара методов (get и set), нечто подобное свойствам C# можно легко сделать в C++. В качестве примера приведу несколько свойств класса Window:

class Window
{
    public:
        // Gets window handle.
        HWND getWindowHandle() const { return m_hWnd; }

        // Gets window title.
        const std::string getTitle() const
        {
            TCHAR buffer[256];
            ::GetWindowText(m_hWnd, buffer, 255);
            return std::string(buffer);
        }

        // Sets window title.
        void setTitle(const std::string& value)
        {
            ::SetWindowText(m_hWnd, value.c_str());
        }
}

Некоторые свойства можно как считывать, так и записывать — в примере выше это свойство Title. Некоторые — только считывать (свойство WindowHandle).
Помимо свойств — функций-членов, которые считывают или записывают некие параметры объекта — конечно же существуют функции-члены, которые выполняют некие действия — в C# они называются методами. Вот например окно можно отобразить на экране или закрыть:

class Window
{
    public:
        // Shows the window on the screen.
        void Show()
        {
            // show window and fill its client area with background brush
            ::ShowWindow(
                m_hWnd,         // window handle
                SW_SHOWNORMAL); // show options

            // redraw window
            ::UpdateWindow(m_hWnd);
        }

        // Closes the window.
        void Close() { ::CloseWindow(m_hWnd); }
}

События — это механизм, при помощи которого объект оповещает о чем-то «заинтересованных лиц» (говорят, что объект генерирует событие). Заинтересованные лица предоставляют объекту функции, которые объект должен вызвать при возникновении события (эти функции еще называют словом callback) — это называется «подписаться на событие». В C# события поддерживаются на уровне синтаксиса языка. В C++ такого нет, но можно написать код, который позволит реализовать события в C++ в том же виде, в каком они имеются в C#. На эту тему я нашел пару статей на сайте Code Project:

С точки зрения реализации, событие — это коллекция указателей на callback-функции. В эту коллекцию можно добавлять новые указатели (подписка на событие) и можно вызвать все функции из коллекции (генерация события). В C# подписка осуществляется при помощи оператора +=, а генерация события — при помощи оператора (). Можно написать класс, который будет содержать коллекцию указателей на функции и определить для него упомянутые операторы. Дело осложняется тем, что функции, которые можно подписать на событие, бывают очень разные — это может быть:

  • свободная функция (т. е. не являющаяся членом какого-либо класса) или статическая функция-член класса
  • невиртуальная экземплярная функция класса
  • виртуальная экземплярная функция класса

В зависимости от вида функции ее приходится вызывать по-разному. Например, чтобы вызвать экземплярную функцию-член класса, надо иметь указатель на экземпляр класса. Если функция-член виртуальная, то ее адрес берется из таблицы виртуальных функций (заставить компилятор вычислить этот адрес во время компиляции — нетривиальная задача). Эти проблемы описаны в упомянутой выше статье [Clugston]. В итоге, воспользовавшись разработкой Don Clugston под названием FastDelegate, я написал класс util::Event, код которого в упрощенном виде приведен ниже:

template <typename TArg>
class Event final
{
    public:
        using EventHandler = fastdelegate::FastDelegate1<TArg>;
   
    private:
        std::vector<EventHandler> m_HandlersCollection;
   
    public:
        Event() :
            m_HandlersCollection()
        { }

        // Adds new event handler to the event handler's collection.
        Event& operator+=(const EventHandler& eventHadler)
        {
            m_HandlersCollection.push_back(eventHadler);
            return *this;
        }

        // Removes event handler from the event handler's collection.
        Event& operator-=(const EventHandler& eventHadler)
        {
            auto iter = std::find(m_HandlersCollection.begin(), m_HandlersCollection.end(), eventHadler);
            m_HandlersCollection.erase(iter);
            return *this;
        }

        // Calls all event handlers.
        void operator()(TArg arg)
        {
            for (EventHandler f : m_HandlersCollection)
                f(arg);
        }
}

Генерация события — это вызов всех функций, указатели на которые хранятся в коллекции m_HandlersCollection. У этих функций могут быть параметры (аргументы). Параметры — это дополнительная информация, которую тот, кто генерирует событие, может передать тем, кто подписан на событие. Сколько может быть таких параметров? Я посчитал, что достаточно одного: если необходимо передать несколько значений разных типов, то можно передать их в виде структуры или кортежа (tuple). Поэтому у класса Event есть ровно один шаблонный параметр TArg — тип аргумента, передаваемого функциям, подписанным на событие.

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

class Window
{
    // Typedefs for delegates
    public:
        using MOUSE_EVENT_DELEGATE = util::Event<std::tuple<Window*, int, int, WPARAM>>;
        using KEYBOARD_EVENT_DELEGATE = util::Event<std::tuple<Window*, WPARAM>>;

    private:
        MOUSE_EVENT_DELEGATE m_MouseMoveEvent;
        MOUSE_EVENT_DELEGATE m_MouseLeftButtonDownEvent;
        MOUSE_EVENT_DELEGATE m_MouseLeftButtonUpEvent;
        KEYBOARD_EVENT_DELEGATE m_KeyDownEvent;
        KEYBOARD_EVENT_DELEGATE m_KeyUpEvent;

    // Events
    public:
        MOUSE_EVENT_DELEGATE& MouseMoveEvent() { return m_MouseMoveEvent; }
        MOUSE_EVENT_DELEGATE& MouseLeftButtonDownEvent() { return m_MouseLeftButtonDownEvent; }
        MOUSE_EVENT_DELEGATE& MouseLeftButtonUpEvent() { return m_MouseLeftButtonUpEvent; }
        KEYBOARD_EVENT_DELEGATE& KeyDownEvent() { return m_KeyDownEvent; }
        KEYBOARD_EVENT_DELEGATE& KeyUpEvent() { return m_KeyUpEvent; }

    // The methods rasing events
    protected:
        virtual void OnMouseMove(int x, int y, WPARAM virtualKeyCode)
        {
            m_MouseMoveEvent(std::make_tuple(this, x, y, virtualKeyCode));
        };

        virtual void OnMouseLeftButtonDown(int x, int y, WPARAM virtualKeyCode)
        {
            m_MouseLeftButtonDownEvent(std::make_tuple(this, x, y, virtualKeyCode));
        };

        virtual void OnMouseLeftButtonUp(int x, int y, WPARAM virtualKeyCode)
        {
            m_MouseLeftButtonUpEvent(std::make_tuple(this, x, y, virtualKeyCode));
        };

        virtual void OnKeyDown(WPARAM virtualKeyCode)
        {
            m_KeyDownEvent(std::make_tuple(this, virtualKeyCode));
        };

    // Window procedure switches among event types and calls methods raising events
    private:
        LRESULT Window::WindowProcedure(
            UINT message,
            WPARAM wParam,
            LPARAM lParam)
        {
            switch (message)
            {
                ...
                case WM_MOUSEMOVE:
                {
                    OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), wParam);
                    break;
                }
                case WM_LBUTTONDOWN:
                {
                    OnMouseLeftButtonDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), wParam);
                    break;
                }
                case WM_LBUTTONUP:
                {
                    OnMouseLeftButtonUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), wParam);
                    break;
                }
                case WM_KEYDOWN:
                {
                    OnKeyDown(wParam);
                    break;
                }
                case WM_KEYUP:
                {
                    OnKeyUp(wParam);
                    break;
                }
                ...
            }
        }
}

Ok, у нас есть события, генерируемые окном. Чтобы на них отреагировать, надо на них подписаться. Ниже показан в упрощенном виде фрагмент кода класса Program, который подписывается на события класса Window:

class Program
{
    private:
        win::Window m_Window;

    public:
        Program() :
            m_Window("Test Window", 1280, 720)
        {
            // Subscribing to events
            m_Window.MouseMoveEvent() += fastdelegate::MakeDelegate(this, &ProgramBase::OnMouseMove);
            m_Window.KeyDownEvent() += fastdelegate::MakeDelegate(this, &ProgramBase::OnKeyDown);
            m_Window.KeyUpEvent() += fastdelegate::MakeDelegate(this, &ProgramBase::OnKeyUp);
        }

    // Event handlers (shown here without implementation)
    private:
        void OnMouseMove(std::tuple<win::Window*, int, int, WPARAM> eventArgs);
        void OnKeyDown(std::tuple<win::Window*, WPARAM> eventArgs);
        void OnKeyUp(std::tuple<win::Window*, WPARAM> eventArgs);
}

Подписывать на события можно не только экземплярные функции-члены классов, как в приведенном примере, но и свободные функции и статические функции-члены классов.
Описание класса Window закончено. Но поскольку всплыл класс Program, скажу пару слов о нем.

Класс Program

Я уже говорил, что очень люблю и уважаю top-down approach (подход «сверху-вниз») в программировании. Взгляните на исходный код функции main ниже. В подходе «сверху-вниз» вы сначала пишете этот код, а уже затем код класса Program.

// main.cpp

#include <iostream>
#include "Program.h"

void main()
{
    try
    {
        Program prog;
        prog.Main();
    }
    catch (std::exception ex)
    {
        std::cout << ex.what() << std::endl;
    }

    std::cout << "Press any key to exit ...";
    std::cin.get();
}

Вроде не бог весть что — просто создается некий объект Program и вся работа программы перепоручается его методу Main. Но есть как минимум два приятных момента от такого подхода: 1) структура функции main проста и понятна 2) нет глобальных переменных — все переменные упрятаны в объекте Program prog. Вот как мог бы выглядеть код класса Program:

class Program
{
    private:
        win::Window m_Window;

    public:
        Program() :
            m_Window("Test Window", 1280, 720)
        { ... }

        void Main()
        {
            m_Window.Show();

            MSG msg{ 0 };
            while (GetMessage(&msg, NULL, 0, 0))
            {
                TranslateMessage(&msg);
                DispatchMessage(&msg);
            }
        }
}

На самом деле в моем коде класс Program наследует классу engine::ProgramBase. Дело в том, что я написал несколько программ, которые рисуют на экране различные сценки, начиная от рисования неподвижного одноцветного треугольника и заканчивая Солнцем, Землей и Луной (см. рисунок в прошлой заметке). Каждая программа — это отдельная версия класса Program. Разумеется у всех этих версий оказалось много общего, и это общее я и поместил в класс engine::ProgramBase. Он-то и содержит и указатель на объект окна, и функцию Main и еще много чего, что мы обсудим в последующих заметках. Но сначала создадим контекст рисования.

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

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