Все статьи Рисуем на OpenGL
В статье мы научимся использовать систему событий SDL2 и рисовать фигуры средствами OpenGL в блоках glBegin/glEnd.
CoreProfile и CompatibilityProfile
Начиная с OpenGL 3.0, стандарт OpenGL описывает два способа создания контекста:
- Compatibility Profile — контекст, совместимый с OpenGL 1.x и 2.x
- Core Profile — контекст, в котором большинство функций версий 1.x и 2.x не действуют, и доступна лишь современная высокопроизводительная модель рисования
Конечно, Core Profile гораздо больше подходит для современных приложений. Но у него высокий порог вхождения, и поэтому мы сначала освоимся в применении Compatibility Profile, где будем можно смешивать функциональность разных версий OpenGL.
Чтобы использовать Compatibility Profile, следует установить глобальный флаг SDL2 до создания контекста OpenGL:
// Выбираем Compatiblity Profile
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK,
SDL_GL_CONTEXT_PROFILE_COMPATIBILITY);
// Создаём контекст OpenGL, связанный с окном.
m_pGLContext.reset(SDL_GL_CreateContext(m_pWindow.get()));
Трансляция событий ввода SDL2
SDL2 вводит единую абстракцию для разных устройств и операционных систем: Windows, MacOSX, Linux, Android, iOS. Есть существенные отличия в устройствах ввода на этих системах.
Например, на мобильных устройствах нет мыши и нет возможности навести курсор на кнопку, не нажимая её. Вместо событий мыши (англ. mouse events) используются события касания (англ. touch events), которые в целом соответствуют друг другу, но имеют различия. Для простоты мы пока что будем опираться на модель событий ввода настольных компьютеров, имеющих мышь и клавиатуру.
SDL2 передаёт события в виде union, а не структуры, в целях экономии памяти. В этот обобщённый union вложены структуры, предназначенные для различных категорий событий:
union SDL_Event
{
Uint32 type; // Тип события, общее поле всех событий
SDL_WindowEvent window; // Структура событий окна
SDL_KeyboardEvent key; // Структура событий клавиатуры
SDL_MouseMotionEvent motion; // Структура событий перемещения курсора
SDL_MouseButtonEvent button; // Структура событий кнопок мыши
SDL_MouseWheelEvent wheel; // Структура событий колеса мыши
// и так далее
}
Чтобы выполнить диспетчеризацию событий в отдельные ветви выполнения, подойдёт инструкция switch
. Чтобы не писать одинаковый и потенциально ошибочный код со switch
в разных местах, вынесем диспетчеризацию отдельно от CAbstractWindow
.
Листинг DispatchEvent.h
#pragma once
#include "AbstractWindow.h"
class IEventAcceptor;
namespace sdl
{
// Совершает диспетчеризацию событий SDL по категориям.
void DispatchEvent(const SDL_Event & event, IEventAcceptor & acceptor);
}
// Принимает события SDL, разделённые по категориям.
// Деление условное и может быть изменено.
class IEventAcceptor
{
public:
virtual ~IEventAcceptor();
// window events group.
virtual void OnResize(const glm::ivec2 &/*size*/) {}
// input events group.
virtual void OnMouseUp(const SDL_MouseButtonEvent &) {}
virtual void OnMouseDown(const SDL_MouseButtonEvent &) {}
virtual void OnMouseMotion(const SDL_MouseMotionEvent &) {}
virtual void OnMouseWheel(const SDL_MouseWheelEvent &) {}
virtual void OnKeyDown(const SDL_KeyboardEvent &) {}
virtual void OnKeyUp(const SDL_KeyboardEvent &) {}
};
// Окно, совершающее диспетчеризацию событий SDL
class CAbstractEventDispatchWindow
: public CAbstractWindow
, public IEventAcceptor
{
protected:
void OnWindowEvent(const SDL_Event &event) final
{
sdl::DispatchEvent(event, *this);
}
};
Реализуем транслятор ввода
Перебор возможных типов события может видоизменяться в зависимости от нужд и модели работы приложения. В данный момент мы не будем заниматься трансляцией touch events в mouse events и обеспечим только базовую диспетчеризацию.
void sdl::DispatchEvent(const SDL_Event &event, IInputEventAcceptor &acceptor)
{
switch (event.type)
{
case SDL_KEYDOWN:
acceptor.OnKeyDown(event.key);
break;
case SDL_KEYUP:
acceptor.OnKeyUp(event.key);
break;
case SDL_MOUSEBUTTONDOWN:
acceptor.OnMouseDown(event.button);
break;
case SDL_MOUSEBUTTONUP:
acceptor.OnMouseUp(event.button);
break;
case SDL_MOUSEMOTION:
acceptor.OnMouseMotion(event.motion);
break;
case SDL_MOUSEWHEEL:
acceptor.OnMouseWheel(event.wheel);
break;
}
}
Теперь получается, что мы можем обработать событие SDL_QUIT в пределах класса CAbstractWindow
и рассылаем события ввода по категориям в дочерних классах, унаследованных от CAbstractEventDispatchWindow
.
Изменение размеров окна
Теперь нам пора изменить класс CAbstractWindow
, чтобы получить нормальную рамку окна без фиксации размера окна. Прежде всего, при создании окна надо указать новый флаг SDL_WINDOW_RESIZABLE
:
// Специальное значение SDL_WINDOWPOS_CENTERED вместо x и y заставит SDL2
// разместить окно в центре монитора по осям x и y.
// Для использования OpenGL вы ДОЛЖНЫ указать флаг SDL_WINDOW_OPENGL.
SDL_CreateWindow(WINDOW_TITLE, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
size.x, size.y, SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE));
Теперь мы должны добавить обработку оконных событий, помеченных категорией SDL_WINDOWEVENT
. Данные этой категории событий хранятся в структуре SDL_WindowEvent
, у которой есть поле event
, описывающее подкатегорию события окна. Для события изменения размера event == SDL_WINDOWEVENT_RESIZED
, а поля data1
и data2
хранят новые значения ширины и высоты соответственно.
Чтобы не нагружать обработкой этих событий метод CAbstractWindow::DoGameLoop
, сделаем следующее: добавим классу CAbstractWindow::Impl
свойство IsTerminated
, которое указывает, был основной цикл окна остановлен или нет. Теперь мы можем переписать DoGameLoop
:
void CAbstractWindow::DoGameLoop()
{
SDL_Event event;
CChronometer chronometer;
while (true)
{
while (SDL_PollEvent(&event) != 0)
{
if (!m_pImpl->ConsumeEvent(event))
{
OnWindowEvent(event);
}
}
if (m_pImpl->IsTerminated())
{
break;
}
// Очистка буфера кадра, обновление и рисование сцены, вывод буфера кадра.
m_pImpl->Clear();
const float deltaSeconds = chronometer.GrabDeltaTime();
OnUpdateWindow(deltaSeconds);
OnDrawWindow(m_pImpl->GetWindowSize());
CUtils::ValidateOpenGLErrors();
m_pImpl->SwapBuffers();
}
}
Метод Impl::ConsumeEvent
реализован следующим образом:
public:
bool ConsumeEvent(const SDL_Event &event)
{
bool consumed = false;
if (event.type == SDL_QUIT)
{
m_isTerminated = true;
consumed = true;
}
else if (event.type == SDL_WINDOWEVENT)
{
OnWindowEvent(event.window);
consumed = true;
}
return consumed;
}
private:
void OnWindowEvent(const SDL_WindowEvent &event)
{
if (event.event == SDL_WINDOWEVENT_RESIZED)
{
m_size = {event.data1, event.data2};
}
}
Размер окна следует всего лишь запомнить, изменение самого окна операционная система и SDL2 совершат самостоятельно. Далее размер окна будет передан в метод OnDrawWindow(const glm::ivec2 &size)
, который вызывается при рисовании очередного кадра — именно так класс CWindow
узнает о размерах области рисования.
Порт просмотра, матрица проецирования
OpenGL рассчитан на проецирование трёхмерной сцены на плоскость объектива виртуальной камеры и не имеет специального режима для 2D. Поэтому мы должны настроить симуляцию трёхмерной камеры, чтобы получить ортографическое проецирование плоскости, в которой мы рисуем фигуры, на клиентскую область окна программы.
В OpenGL всё рисование происходит в трёхмерных координатах. Затем, в процессе обработки данных, видеодрайвер преобразует все поверхности и другие объекты виртуального трёхмерного мира в нормализованную систему координат. Существует два основных способа преобразования:
- ортографическое преобразование отбрасывает одну координату и вписывает некоторый прямоугольник в нормализованный куб
- перспективное проецирование искажает координаты, чтобы учесть перспективу, при этом некоторое пространство, описанное усечённой пирамидой, вписывается в нормализованный куб
Нормализованная система координат устройства – система координат, получаемая после выполнения операции перспективного или ортографического преобразования, в которой левому краю видового порта соответствует координатная плоскость X=-1, правому краю – X=1, нижнему краю – Y=-1, верхнему – Y=1, ближней плоскости отсечения – Z=-1, а дальней – Z=+1. Для установки матрицы ортографического преобразования следует сделать три вещи:
- переключить режим операций с матрицами с GL_MODELVIEW, включённого по умолчанию, на GL_PROJECTION вызовом функции-команды
glMatrixMode(GL_PROJECTION)
- создать или загрузить готовую матрицу ортографического преобразования
- переключить режим операций с матрицами обратно на GL_MODELVIEW вызовом функции-команды
glMatrixMode(GL_MODELVIEW)
При помощи преобразования в порт просмотра координаты вершин примитивов из нормализованной системы координат устройства преобразовываются в оконные координаты. Порт просмотра (англ. viewport) обычно занимает всю клиентскую область окна, но это не обязательно. Установка размеров видового порта в координатах клиентской области окна производится функцией-командой glViewport(x, y, width, height)
.
Для установки видового порта и матрица проецирования введём вспомогательный метод SetupView
, принимающий один параметр — размер окна в виде вектора 2-х целых чисел.
1-я реализация SetupView
void CWindow::SetupView(const glm::ivec2 &size)
{
glViewport(0, 0, size.x, size.y);
glMatrixMode(GL_PROJECTION);
// Загружаем единичную матрицу
glLoadIdentity();
// Умножаем текущую матрицу на матрицу ортографического
// проецирования из параллелепипеда с размером, равным size,
// с отбрасыванием координаты z; по координате z параллепипед
// ограничен параметрами zNear и zFar, которые также называются
// "ближняя плоскость отсечения" и "дальняя плоскость отсечения".
glOrtho(/*left*/ 0, /*right*/ size.x, /*bottom*/ size.y,
/*top*/ 0, /*zNear*/ -1, /*zFar*/ 1);
glMatrixMode(GL_MODELVIEW);
}
Здесь мы воспользовались встроенными в OpenGL 1.x функциями для работы с матрицами. Существует целое семейство таких функций:
- glLoadIdentity() для загрузки единичной матрицы
- glTranslatef() и glTranslated() для трансформации перемещения
- glRotatef() и glRotated() для трансформации вращения
- glScalef() и glScaled() для трансформации масштабирования
- glOrtho() для матрицы ортографического проецирования
- glFrustum() для матрицы перспективного проецирования
- glPushMatrix() для сохранения матрицы на вспомогательный стек
- glPopMatrix() для восстановления матрицы из вспомогательного стека
- glLoadMatrixf() и glLoadMatrixd() для явной установки матрицы
Перепишем код на GLM
К сожалению, в будущем мы потеряем возможность прямой работы с матрицами: в Core Profile эта функциональность не работает. Поэтому мы постараемся максимально избегать работы с матрицами средствами OpenGL, и применим только две функции-команды: glMatrixMode
и glLoadMatrixf
. Всё остальное сделаем с помощью библиотеки GLM.
2-я реализация SetupView
void CWindow::SetupView(const glm::ivec2 &size)
{
// Матрица ортографического проецирования изображения в трёхмерном пространстве
// из параллелепипеда с размером, равным (size.X x size.Y x 2).
const glm::mat4 matrix = glm::ortho<float>(0, size.x, size.y, 0);
glViewport(0, 0, size.x, size.y);
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(glm::value_ptr(matrix));
glMatrixMode(GL_MODELVIEW);
}
Immediate mode в OpenGL
Основные особенности OpenGL до версии 3.0 — это фиксированный конвейер (англ. fixed graphics pipeline) и безотлагательный режим исполнения (англ. immediate mode).
Основным принципом работы OpenGL является получение набора векторных примитивов в виде точек, отрезков прямых, треугольников, многоугольников с последующей математической обработкой полученных данных и визуализацией в виде растрового изображения на экране или в памяти. Трансформации векторов и растеризация выполняются фиксированным конвейером, представляющим из себя дискретный автомат. Команды OpenGL версий 1.x и 2.x либо добавляют графические примитивы на вход конвейера, либо конфигурируют конвейер для выполнения различных трансформаций.
Immediate mode в OpenGL требует от программиста диктовать точную последовательность шагов для построения результирующего растрового изображения. В отличие от декларативных подходов, когда вся трехмерная сцена передается в виде структуры данных (например, дерева), которое обрабатывается и строится на экране, императивный подход, используемый в OpenGL, требует от программиста глубокого знания трехмерной графики и математических моделей. С другой стороны, программисту предоставляется большая гибкость и свобода действий.
Рисование контура звезды
Следующим нашим шагом будет рисование синей пятиконечной звезды. Звезда будет задана в виде замкнутой ломаной линии из пяти вершин:
OpenGL 1.x позволяет рисовать группы различных примитивов путем перечисления вершин примитивов в нужном порядке между вызовами функций glBegin и glEnd. Координаты вершин задаются при помощи семейства функций glVertex.
Вершины нашей пятиконечной звезды располагаются на окружности в точках, соответствующих углам -90°, 54°, 198°, 342° и 486° (126°) – каждая последующая вершина сдвинута на 360 * 2 / 5 = 144° относительно предыдущей. Оформим рисование пятиконечной звезды в виде функции Stroke5PointStar:
// Рисуем пятиконечную звезду
void Stroke5PointStar(float xCenter, float yCenter, float radius)
{
const float STEP = float(M_PI * 4 / 5);
// Начинаем новую группу примитивов (замкнутая ломаная линия)
glBegin(GL_LINE_LOOP);
float angle = float(-M_PI_2);
// Соединяем отрезками прямой линии точки, расположенные на окружности
// в точках, с углами: -90, 54, 198, 342, 486 (126) градусов
for (int i = 0; i < 5; ++i, angle += STEP)
{
float x = xCenter + radius * cosf(angle);
float y = yCenter + radius * sinf(angle);
// функция glVertex2f добавляет в текущую группу примитивов
// точку, лежащую на плоскости z = 0
// суффикс 2f в названии функции обозначает, что задаются 2 координаты
// x и y типа GLfloat
glVertex2f(x, y);
}
// Заканчиваем группу примитивов
glEnd();
}
Если после вызова этой функции с подходящими аргументами звезда всё ещё не нарисована, следует проверить:
- нет ли ошибок, возвращённых запросом
glGetError
- правильно ли настроена матрица ортографического проецирования
- установлен ли цвет рисования, отличный от цвета заливки. Установить цвет рисования внутри glBegin/glEnd можно функцией glColor3f.
Рисование контура эллипса
В OpenGL отсутствуют функции для рисования криволинейных фигур (сделано это с целью повышения производительности), поэтому эллипс будет аппроксимирован в виде незамкнутой ломаной линии (примитив GL_LINE_STRIP), содержащей в нашем случае 360 вершин.
// Рисуем контур эллипса
void StrokeEllipse(float xCenter, float yCenter, float rx, float ry, int pointCount = 360)
{
const float step = float(2 * M_PI / pointCount);
// Эллипс представлен в виде незамкнутой ломаной линии, соединяющей
// pointCount точек на его границе с шагом 2*PI/pointCount
glBegin(GL_LINE_STRIP);
for (float angle = 0; angle < float(2 * M_PI); angle += step)
{
const float dx = rx * cosf(angle);
const float dy = ry * sinf(angle);
glVertex2f(dx + xCenter, dy + yCenter);
}
glEnd();
}
Рисование залитого цветом эллипса
Функция FillEllipse рисования эллипса использует группу примитивов GL_TRIANGLE_FAN (веер из треугольников) для рисования закрашенного эллипса. Способ задания вершин группы примитивов Triangle Fan показан на следующем рисунке:
// Рисуем закрашенный эллипс
void FillEllipse(float xCenter, float yCenter, float rx, float ry, int pointCount = 360)
{
const float step = float(2 * M_PI) / pointCount;
// Эллипс представлет в виде "веера" из треугольников
glBegin(GL_TRIANGLE_FAN);
// Начальная точка веера располагается в центре эллипса
glVertex2f(xCenter, yCenter);
// Остальные точки - равномерно по его границе
for (float angle = 0; angle <= float(2 * M_PI); angle += step)
{
float a = (fabsf(angle - float(2 * M_PI)) < 0.00001f) ? 0.f : angle;
const float dx = rx * cosf(a);
const float dy = ry * sinf(a);
glVertex2f(dx + xCenter, dy + yCenter);
}
glEnd();
}
Результат
В конечном счёте, методы рисования окна и настройки проецирования можно добавить в класс CWindow, а три функции рисования примитивов — разместить в анонимном пространстве имён в Window.cpp
. Тогда тела методов могут выглядеть следующим образом:
void CWindow::OnDrawWindow(const glm::ivec2 &size)
{
SetupView(size);
// Рисуем эллипс (как ломаную линию)
glColor3f(1, 0, 0);
StrokeEllipse(200, 340, 20, 120);
// Рисуем закрашенный эллипс
glColor3f(0, 1, 1);
FillEllipse(150, 120, 100, 90);
// Рисуем пятиконечную звезду (как ломаную линию)
glColor3f(1, 1, 1);
Stroke5PointStar(350, 210, 100);
}
void CWindow::SetupView(const glm::ivec2 &size)
{
// Матрица ортографического проецирования изображения в трёхмерном пространстве
// из параллелепипеда с размером, равным (size.X x size.Y x 2).
const glm::mat4 matrix = glm::ortho<float>(0, size.x, size.y, 0);
glViewport(0, 0, size.x, size.y);
glMatrixMode(GL_PROJECTION);
glLoadMatrixf(glm::value_ptr(matrix));
glMatrixMode(GL_MODELVIEW);
}
После запуска получаем: