Опыт изучения OpenGL — Часть 4 — Класс engine::ProgramBase

Я продолжаю рассказывать о написанном мною рисовательном API на базе OpenGL, размещенном в открытом репозитории на хостинге BitBucket. К сожалению, написание программы с помощью OpenGL требует существенной предварительной артподготовки в виде

  1. Загрузки функций OpenGL (инициализации библиотеки GLEW)
  2. Создания окна
  3. Создания контекста OpenGL

В сегодняшней заметке я освещу класс engine::ProgramBase, который собирает все перечисленные этапы воедино, и в следующих заметках перейду непосредственно к рисованию.

Задачи класса ProgramBase

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

  1. Проинициализировать библиотеку GLEW
  2. Создать окно
  3. Создать контекст OpenGL
  4. Реализовать цикл обработки сообщений
  5. Реализовать измерение времени и отрисовку графики в окне
  6. Реализовать обработчики некоторых событий окна

Инициализация GLEW, создание окна и контекста OpenGL

class ProgramInitializer
{
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 нашей программы.

class ProgramBase
{
    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:

namespace win { namespace 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 является чисто виртуальной и должна быть переопределена в производном классе.

class 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, на которое обязательно необходимо подписаться.

class ProgramBase
{
    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).

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

Ваш адрес email не будет опубликован.