В прошлой заметке я начал рассказывать про свой опыт изучения 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) это может выглядеть примерно так:
{
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? По моему скромному мнению, процесс несколько забюрократизирован.
- Создать и зарегистрировать т. н. класс окна (функция RegisterClassEx). Здесь под классом окна понимается структура WNDCLASSEX — не путать с классом в языке C++. В структуре WNDCLASSEX указывается ряд параметров окна, в том числе адрес оконной процедуры WndProc — той процедуры, которую вызывает функция DispatchMessage (см. выше). На основе класса окна можно насоздавать много окон со схожими параметрами (очевидно для этого понятие «класс окна» и было введено).
- Создать окно на основе класса окна (функция CreateWindow), указав еще ряд параметров, таких как текст заголовка и размеры. Эти параметры, очевидно, считаются уникальными для каждого окна, поэтому они не входят в структуру класса окна WNDCLASSEX.
WinAPI так же как и OpenGL является объектно-ориентированным (несмотря на то, что это C-API). Например, окно — это объект. Каждый объект в WinAPI идентифицируется так называемым дескриптором (он же — описатель, он же — хэндл). Этот дескриптор возвращает функция WinAPI, которая создает объект, например CreateWindow. Дескриптор следует сохранить, чтобы использовать его для взаимодействия с объектом. При создании окна его дескриптор (переменная типа HWND) логично будет сохранить в поле класса Window.
Созданием окна должен заниматься конструктор класса. Тут возникает пара трудностей:
- Создавать и регистрировать класс окна нужно только один раз за время работы программы, а не каждый раз при создании окна. В C# подобные проблемы решаются при помощи статического конструктора, который вызывается при первом обращении к классу. В C++ нет понятия статического конструктора, но его можно легко реализовать — создать статическую функцию и вызывать ее в каждом конструкторе класса. Функция может определить, вызывали ли ее хотя бы раз при помощи статической переменной. В моем коде эта функция называется InitWndClass.
-
В конечном счете обрабатывать сообщение, посланное окну, по идее должна экземплярная функция-член класса Window. Но сообщение передается оконной процедуре, а оконная процедура принадлежит не конкретному окну, а всему классу окон в целом. Разумеется, оконная процедура не может быть экземплярной функцией-членом класса. Идентифицировать окно, которому адресовано сообщение, можно по дескриптору окна, который содержится в структуре MSG. Таким образом, оконная процедура должна по дескриптору найти экземпляр окна и вызвать его функцию-член, которая обработает сообщение. Для этого придется завести некий глобальный массив, куда помещать ссылки на экземпляры окон при их создании. Тогда оконная процедура сможет найти нужное окно в этом массиве. В моей программе этим массивом является ассоциативный массив std::map
s_RegisteredWindows .
Приведу здесь в упрощенном виде код класса 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:
{
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# они называются методами. Вот например окно можно отобразить на экране или закрыть:
{
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:
- Arnold the Aardvark — Emulating C# delegates in Standard C++, 24 Feb 2004
- Don Clugston — Member Function Pointers and the Fastest Possible C++ Delegates, 5 Apr 2005
С точки зрения реализации, событие — это коллекция указателей на callback-функции. В эту коллекцию можно добавлять новые указатели (подписка на событие) и можно вызвать все функции из коллекции (генерация события). В C# подписка осуществляется при помощи оператора +=
, а генерация события — при помощи оператора ()
. Можно написать класс, который будет содержать коллекцию указателей на функции и определить для него упомянутые операторы. Дело осложняется тем, что функции, которые можно подписать на событие, бывают очень разные — это может быть:
- свободная функция (т. е. не являющаяся членом какого-либо класса) или статическая функция-член класса
- невиртуальная экземплярная функция класса
- виртуальная экземплярная функция класса
В зависимости от вида функции ее приходится вызывать по-разному. Например, чтобы вызвать экземплярную функцию-член класса, надо иметь указатель на экземпляр класса. Если функция-член виртуальная, то ее адрес берется из таблицы виртуальных функций (заставить компилятор вычислить этот адрес во время компиляции — нетривиальная задача). Эти проблемы описаны в упомянутой выше статье [Clugston]. В итоге, воспользовавшись разработкой Don Clugston под названием FastDelegate, я написал класс util::Event, код которого в упрощенном виде приведен ниже:
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, касающийся событий мыши и клавиатуры:
{
// 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:
{
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.
#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:
{
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 и еще много чего, что мы обсудим в последующих заметках. Но сначала создадим контекст рисования.