Я продолжаю рассказывать о написанном мною рисовательном API на базе OpenGL, размещенном в открытом репозитории на хостинге BitBucket. К сожалению, написание программы с помощью OpenGL требует существенной предварительной артподготовки в виде
В сегодняшней заметке я освещу класс engine::ProgramBase, который собирает все перечисленные этапы воедино, и в следующих заметках перейду непосредственно к рисованию.
Задачи класса ProgramBase
Все программы, которые что-то рисуют при помощи OpenGL, как оказалось, имеют ряд общих черт. В объектно-ориентированном программировании, если у нескольких классов есть нечто общее, то это общее принято помещать в базовый класс. Таким образом класс ProgramBase в моем проекте служит базовым классом для всевозможных программ, которые что-то рисуют в окне. И вот ряд общих задач, которые всем этим программам приходится решать и которые вынесены в класс ProgramBase:
- Проинициализировать библиотеку GLEW
- Создать окно
- Создать контекст OpenGL
- Реализовать цикл обработки сообщений
- Реализовать измерение времени и отрисовку графики в окне
- Реализовать обработчики некоторых событий окна
Инициализация GLEW, создание окна и контекста OpenGL
{
protected:
ProgramInitializer()
{
opengl::Initialize_GLEW_Library();
std::cout << "GLEW library initialized successfully" << std::endl;
}
};
class ProgramBase : private ProgramInitializer
{
private:
win::Window m_Window;
opengl::OpenGLWindow m_GLWindow;
public:
ProgramBase() :
m_Window("Test Window", 1280, 720),
m_GLWindow(m_Window.getDeviceContext(), 4, 3)
{ }
}
Тут нужно понимать последовательность действий программы при вызове конструктора класса, у которого есть базовый класс. Сначала вызывается конструктор базового класса. Затем инициализируются поля производного класса, причем в том порядке, в котором они объявлены в теле класса, а не в том порядке, в котором они перечислены в списке инициализации (чтобы избежать сомнений, рекомендуется делать тот и другой порядок одинаковыми). И наконец, выполняется тело конструктора производного класса. Вот почему я был вынужден создать базовый класс ProgramInitializer для класса ProgramBase — мне нужно было, чтобы инициализация GLEW выполнилась раньше, чем создание объекта OpenGLWindow m_GLWindow.
Цикл обработки сообщений
Цикл обработки сообщений вы уже видели в заметке про окно. Но нелишне будет привести его здесь еще раз. Цикл находится в методе ProgramBase::Main(), который вызывается из функции main нашей программы.
{
public:
void Main()
{
m_Window.Show();
MSG msg{ 0 };
while (msg.message != WM_QUIT)
{
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
//TranslateMessage(&msg);
DispatchMessage(&msg);
}
else
UpdateTimeAndRender();
}
}
}
Тут появляются некоторые дополнительные детали. Во-первых перед тем, как запустить цикл обработки сообщений, мы отображаем окно на экране (m_Window.Show()
). Во-вторых в цикле мы не только обрабатываем сообщения. Функция PeekMessage возвращает ненулевое значение (TRUE), если в очереди есть сообщение, и нулевое (FALSE) — в противном случае. Так вот: если сообщений в очереди нет и обрабатывать нам нечего, то мы обновляем состояние программы и перерисовываем изображение на экране (UpdateTimeAndRender()
). Что значит «обновляем состояние программы»? В компьютерных играх обычно существует понятие «время»: даже если пользователь ничего не делает (и поэтому не генерирует никаких сообщений), время в игре все равно течет: NPC (non-player characters) занимаются своими делами, деревья и трава раскачиваются от ветра, вода течет и прочее. Тут и возникает проблема измерения времени.
Измерение времени
Об измерении времени я прочитал в книге [Jason Gregory — Game Engine Architecture, 7.5.3. Measuring Real Time with a High-Resolution Timer]. В центральном процессоре есть регистр, предназначенный для измерения времени, содержимое которого инкрементируется каждый период тактового сигнала. Операционная система предоставляет API для чтения этого регистра и определения времени по его содержимому. WinAPI предоставляет две простые функции: QueryPerformanceCounter возвращает число тактов с момента запуска ОС (т. е. значение того самого регистра), а QueryPerformanceFrequency возвращает число тактов в секунду. Разделив одно на другое, получим число секунд с момента запуска ОС. Чтобы сделать работу с этими функциями чуть более удобной, я сделал аналогичные две функции, которые поместил в пространство имен под названием win::HighResolutionTimer:
{
// Gets the number of ticks passed since the start of the operation system.
static uint64_t getTicks()
{
LARGE_INTEGER ticks;
if (QueryPerformanceCounter(&ticks))
return ticks.QuadPart;
else
throw make_winapi_error("HighResolutionTimer::getTicks() -> QueryPerformanceCounter()");
}
// Gets the number of ticks per second.
static uint64_t getTicksPerSecond()
{
static uint64_t s_TicksPerSecond{0};
if (s_TicksPerSecond == 0)
{
LARGE_INTEGER freq;
if (QueryPerformanceFrequency(&freq))
s_TicksPerSecond = freq.QuadPart;
else
throw make_winapi_error("HighResolutionTimer::getTicksPerSecond() -> QueryPerformanceFrequency()");
}
return s_TicksPerSecond;
}
}};
Представленные функции используют WinAPI. Альтернативный вариант — воспользоваться для измерения времени стандартной библиотекой chrono.
Оказалось, что для того, чтобы обновлять состояние программы с течением времени, мне достаточно знать две величины: текущее значение времени (например, с момента старта программы) и время прошедшее с последнего обновления состояния программы. Вычисление этих величин происходит в функции ProgramBase::UpdateTimeAndRender, а непосредственное обновление состояния программы — в функции ProgramBase::UpdateTime, которая в классе ProgramBase является чисто виртуальной и должна быть переопределена в производном классе.
{
private:
void UpdateTimeAndRender()
{
static uint64_t timeStart = win::HighResolutionTimer::getTicks();
static uint64_t lastFrameTime = 0;
uint64_t currentTime = win::HighResolutionTimer::getTicks() - timeStart;
UpdateTime(
/*currentTime*/ currentTime / static_cast<double>(win::HighResolutionTimer::getTicksPerSecond()),
/*timeDelta*/ (currentTime - lastFrameTime) / static_cast<double>(win::HighResolutionTimer::getTicksPerSecond()));
lastFrameTime = currentTime;
m_GLWindow.Render();
}
protected:
/*
Updates the state of the program with time.
Parameters:
currentTime - time in seconds passed since the start of the program.
timeDelta - time in seconds passed since the last call to UpdateTime().
*/
virtual void UpdateTime(double currentTime, double timeDelta) = 0;
}
Обработка событий окна
Некоторые события окна обрабатываются более или менее одинаковым образом во всех вариантах программы, поэтому можно обработчики этих событий реализовать в классе ProgramBase. Это события необходимости перерисовки (PaintEvent), нажатия-отпускания клавиш (KeyDownEvent, KeyUpEvent), движения мыши (MouseMoveEvent), изменения размера окна (SizeChangedEvent). Подписку на эти события я вынес в отдельный метод InitEventHandlers, который вызывается в конструкторе класса ProgramBase. Также есть событие OpenGLWindow::RenderEvent, на которое обязательно необходимо подписаться.
{
public:
ProgramBase() :
...
{
...
InitEventHandlers();
...
}
private:
void InitEventHandlers()
{
m_Window.PaintEvent() += fastdelegate::MakeDelegate(this, &ProgramBase::OnPaint);
m_Window.MouseMoveEvent() += fastdelegate::MakeDelegate(this, &ProgramBase::OnMouseMove);
m_Window.KeyDownEvent() += fastdelegate::MakeDelegate(this, &ProgramBase::OnKeyDown);
m_Window.KeyUpEvent() += fastdelegate::MakeDelegate(this, &ProgramBase::OnKeyUp);
m_Window.SizeChangedEvent() += fastdelegate::MakeDelegate(this, &ProgramBase::OnSizeChanged);
m_GLWindow.RenderEvent() += fastdelegate::MakeDelegate(this, &ProgramBase::OnRender);
}
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);
void OnPaint(std::tuple<win::Window*, HDC> eventArgs) { UpdateTimeAndRender(); }
void OnSizeChanged(std::tuple<win::Window*, int, int> eventArgs);
virtual void OnRender(opengl::OpenGLWindow* sender) = 0;
}
Обработчики событий клавиатуры и мыши занимаются тем, что изменяют позицию в пространстве и направление взгляда камеры. Понятие камеры конечно же имеет смысл только в трехмерном мире, мы обсудим что это такое в последующих заметках. Обработчик события SizeChangedEvent тоже имеет прямое отношение к камере — он устанавливает так называемый viewport, об этом тоже в следующих заметках. Обработчик события PaintEvent просто вызывает метод UpdateTimeAndRender. И наконец, обработчик события OpenGLWindow::RenderEvent является чисто виртуальной функцией и должен быть переопределен в производном классе, поскольку только производный класс «знает» что и как рисовать на экране.
В следующей заметке мы наконец займемся рисованием при помощи функций OpenGL и начнем с рисования цветного треугольника (с чего обычно начинаются все книжки по OpenGL).