Создание 3D игр на C++
Часть1. Урок 5
Создаем трехмерную сцену. Шаг 1 - Рисование

Сегодня мы нарисуем нашу первую трехмерную сцену. Без освещения и текстурирования - это темы ближайших уроков. Пока простейшее рисование сцены.

Порт просмтотра

Порт просморта задается командой
glViewPort (GLint left, GLint top, GLint right, GLint bottom);
Четыре параметра задают координаты прямогуольника на форме, в котором будет выводиться сцена.

Проекция

Прежде, чем мы займемся созданием сцены, нам надо установить, собственно, размеры этой сцены.
В предыдущем уроке мы заикались об этом, но особо не затрагивали. Теперь поговорим подробнее.
Перво наперво надо сменить матрицу на матрицу проекции (чуть позже разберемся):
glMatrixMode (GL_PROJECTION);
Есть два типа проекций: перспективная и ортогональная. Перспективная (которую предстоит использовать нам) - это проецкия, в которой более отдаленные предметы кажутся меньше, чем более близкие. При этом сцена представляет из себя усеченную пирамиду с прямоугольником в основании. Ортогональная (которая очень широко используется, скажем, в архитектуре) - это проекция, в которой вы всегда видите реальный размер объекта, независимо от удаления (есть еще одно очень широко распространенное толкование ортогональной проекции: перспективная проекция при бесконечном удалении зрителя от сцены). При этом сцена представляет из себя параллелипипед.
Средствами модуля gl.h перспективная проекция устанавливается следующим образом:
glFrustum (GLdouble left, GLdouble right, GLdouble bottom, GLdouble top, GLdouble near, GLdouble far);
Здесь шесть параметров задают соответсвенно шесть границ сцены. При этом near - это ближайшая точка, то есть точка, лежащая в плоскости экрана монитора. Far - это максимально удаленная точка. При изменении значения far не изменяется отображение сцены. Оно лишь влияет на то, какие объекты будет отсечены.
Для задания ортогональная проекции используется команда glOrtho с теми же параметрами.
Но, как мы знаем, помимо gl.h есть еще библиотека утилит OpenGL, именуемая GLU, и определенная в заголовочном файле glu.h. Среди прочих нам представляется очень удобная функция gluPerspective, Она была разобрана на прошлом уроке. Добавлю лишь, что она в конечном счете сводится к вызову glFrustum.

Камера

Все дальшейшие операции мы будем производить с матрицей видового преобразования, к которой можно перейти командой (опять же, разберем позже)
glMatrixMode (GL_MODELVIEW);
Итак, камера.
Изначально камера установлена в точку 0, 0, 0 и направлена вдоль отрицательной полуоси Oz.
Есть два основных способа изменить ее положение: переместить сцену командами glRorate* и glTranslate* или изменить положение камеры (что, правда, и сведется к вызову команд glRotate* и glTranslate*) с помощью команды gluLookAt.
Итак, первый способ - изменение координат сцены. Вы можете их менять неоднократно. Например, вывести шар, потом сменить систему координат, потом вывести уже в новой системе координат куб итд. Кроме того, вы можете в ходе создания сцены поменять проекцию! То есть часть сцены будет отображена в ортогоняльная проекции, а часть - в перспективной. Но сейчас не об этом. Итак, формат команд таков:
glTranslate* (x, y, z);
glRotate* (angle, i, j, k);
Сначала о том, что значит звездочка в записи. Это говорит о том, какой тип у передаваемых параметров. Вместо звездочки ставится буква, значащая тип передаваемых значений. Я использую f (GLfloat) и вам советую.
То есть команды будут выглядеть так:
glTranslatef (x, y, z);
glRotatef (angle, i, j, k);
Так же можно использовть i (GLint), d (GLdouble) и еще несколько букв. Если после этой буквы еще добавить v (например, glTranslatefv), то передавать параметры надо будет не отдельными переменными, а массивом из трех элементов.
Итак, что делают команды. Первая (glTranslate*) смещает текущую систему координат на вектор x, y, z. После этого все координаты при рисовании объектов будут пересчитывать для этой системы координат. То есть, если вы сместите ее на 5 по x и на 5 по y, а потом выведете точку с координатой 5, -5, 0, то она появится в точке 5, -5, 0 в новой системе координат или в точке 10, 0, 0 в старой, что в сущности, одно и то же.
Вторая команда (glRotatef) поворачивает текущую систему координат на угол angle вокруг вектора i, j, k.
С этими двумя командами возникает много проблем, хотя на первый взгляд они кажутся (в свое время казались мне, как минимум) очень простыми. Сегодня мы разберем основы создания OpenGL проектов, и после этого я вам советую как можно больше попрактиковаться с их применением.

Более простой способ установить камеру - это команда из библиотеки утилит GLU gluLookAt. Формат ее таков:
gluLookAt (GLfloat x, GLfloat y, GLfloat z, GLfloat seex, GLfloat seey, GLfloat seez, GLfloat upx, GLfloat upy, GLfloat upz);
Первая тройка параметров говорит о координатах, в которых находится камера. Вторая тройка говорит о координатах любой точки, на которую камера направлена. Третья тройка - это координаты любой точки, которая находится над камерой.

Матрица

Сейчас вкратце разберем значение функции glMatrixMode.
В каждый момент времени вы работаете с одной из матриц. Нам предстоит работать с двумя: матрицей проекии (PROJECTION) и матрицей видового преобразования (MODELVIEW). Первая отвечает за проекцию сцены на экране, вторая за расположение камеры (грубо говоря) в пространстве. Соответственно, если вам необходимо изменить проекцию сцены, то прежде, чем вызывать команды glFrustum, glOrtho или gluPerspective, вам необходимо обязательно сменить проекцию на GL_PROJECTION. Иначе результат выполнения команд неизвестен.
Соответственно перед вызовом любых функций перемещения камеры, таких как glRotate*, glTranslate* и glScale*, надо установить текущей матрецей матрицу видового преобразования. В дальнейшем изменение позиции камеры мы будем называть изменением матрицы видового преобразования.
Для сброса матрицы в изначальное состояние используется функция
glLoadIdentity ();
После ее вызова текущая матрица станет единичной. Ее рекомендуется вызывать перед каждой отрисовкой сцены, и после ее вызова заново устанавливать камеру.
В приложениях есть немного полезной информации о матрицах и операциях над ними.

Рисование

Итак, как же происходит рисование объяетов средствами OpenGL. Библиотека gl.h представляет нам лишь один способ (скорее всего, с ходом рассылки мы с вами изучим еще несколько способов). Это так называемые вертексы. Вертекс - это точка в пространстве. Благодаря различным способам соединения вертексов получаются различные геометрические объяеты. Для того, чтобы начать строить вертексы, надо вызвать команду
glBegin (GLenum type);
А закончить
glEnd ();
Где type - одна из следующих констант:

GL_POINTS - каждый вертекс будет просто добавлять точку на сцену.
GL_LINES - каждые два вертекса будут добавлять на сцену отрезок, стянутый ими.
GL_LINE_STRIP - вертексы образуют ломанную кривую.
GL_LINE_LOOP - все перечисленные между glBegin и glEnd вертексы создадут замкнутую ломанную.
GL_TRIANGLES - каждые три вертекса образуют новый треугольник.
GL_TRIANGLE_STRIP - строит треугольники, каждые два подрад идущих треугольника имеют две общих вершины.
GL_TRIANGLE_FAN - строит треугольники, притом первый вертекс будет являться вершиной всех треугольников.
GL_QUADS - каждый четыре вертекса образуют прямоугольник.
GL_QUAD_STRIP - создает прямогульники, каждые два подряд идущих имеют две общих вершины
GL_POLYGON - все вертексы образуют ВЫПУКЛЫЙ (вогнутый нельзя!) многоугольник

Мы будем пользоваться многими из них, там я и буду давать более подробную информацию.
Для того, чтобы нарисовать вертекс, надо вызвать команду
glVertex3f (GLfloat x, GLfloat y, GLfloat z);
Эта команда принимает три параметра - три координаты вертекса.

Цвет

Чтобы задать цвет вертекса (сейчас поясню), необходимо вызвать команду
glColor3f (GLfloat r, GLfloat g, GLfloat b);
Которая принимает в качестве параметров три переменных - составляющие красного, зеленого и синего цветов.
Итак, немного пояснений. Если вы вызовете между двумя вертексами команду дважды, то первая будет попросту проигнорирована. Также, если вам надо нарисовать четыре красных вертекса, совсем не обязательно вызывать команду смены цвета перед каждый, достаточно вызвать ее однажды.
Здесь может возникнуть вполне очевидный вопрос, а что, если я рисую квадрат, и задам разные цвета вертесам, составляющим квадрат (или любую другую фигуру). Это полностью в вашей власти. До того, как вы начнете рисовать сцену (лучше всего - при запуске программы), вызовите команду
glShadeModel (GLenum mode);
Где mode - одна из следующих констант:
GL_SMOOTH - если в фигуре вертексы имеют различный цвет, то цвета будут просто переливаться друг в друга.
GL_FLAT - если в фигуре вертексы имеют различный цвет, то вся фигура красится в цвет первого вертекса.

Z-буффер (Depth)

Теперь пара слов о Z-буфере. Для начала небольшой экскурс в то, как работает OpenGL. После вызова любых команд, выводящих объект, OpenGL вычисляет положение фигуры на экране и запоминает ее, преобразовав в двухмерный массив пикселей (точек). И тут возникает проблема. Если выводится объект, и выясняется, что он пересекается с одним из ранее нарисованных, то есть какие-то из точек уже определены (ведь мы помним только двухмерный вариант старого объекта, хранить все объекты в трехмерном плане было бы накладно), то какие точки вывести на экран, старые, или новые. Вы, конечно, скажете, того объекта, который ближе. Во-первых, вы не совсем будете правы, так как может оказаться так, что нет более близкого объекта, то есть какие-то точки ближе у одного объекта, какие-то у другого. Во-вторых, мы не помним, как далеко удален объект от сцены, мы же помним только его двумерный вариант. Потому, если не включить Z-буффер (называемый еще тестом глубины), то каждый новый нарисованный объект будет нарисован поверх всех остальных, даже если он дальше их в проекции (что глупо). Если же включен тест глубины (Z-буффер), то для каждой двумерной точки запоминается еще так называемая z-составляющая, удаление от сцены. Тогда, при выводе объекта, если какая-то точка уже определена, мы просто сравниваем ее удаление с удалением новой претендующей точки, и если новая ближе, то мы забываем про старую, и на ее место помещаем новую, в противном случае оставляем старую.
Для того, чтобы включить тест глубины, надо вызвать команду
glEndble (GL_DEPTH_TEST);

Простой пример

Итак, создадим простой пример. Создайте в C++Builder новый проект и запишите в конструктор формы такой код инициализации:
HGLRC hGLRC;
HDC   hDC;
__fastcall TForm1::TForm1(TComponent* Owner)
   : TForm(Owner)
{
   hDC = GetDC(Handle);

   int PixelFormat;
   PIXELFORMATDESCRIPTOR pfd =
   {sizeof(PIXELFORMATDESCRIPTOR), 1,
      PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER,
      PFD_TYPE_RGBA, 24, 0,0,0,0,0,0, 0,0, 0,0,0,0,0, 32, 0,
      0, PFD_MAIN_PLANE, 0, 0,0,};
   PixelFormat = ChoosePixelFormat (hdc, &pfd);
   if (SetPixelFormat (hdc, PixelFormat, &pfd) == FALSE)
      Application->Terminate ();

   hGLRC = wglCreateContext(hDC);
   if(hGLRC == NULL) Application->Terminate ();
   if(wglMakeCurrent(hDC, hGLRC) == false) Application->Terminate ();

   glEnable (GL_DEPTH_TEST);

   glViewport (0, 0, Width, Height);

   glShadeModel (GL_SMOOTH);
   glClearColor (0.0, 0.0, 0.0, 1.0);

   glMatrixMode (GL_PROJECTION);
   glLoadIdentity ();
   gluPerspective (90, GLdouble(Width/Height), 1.0, 100.0);

   glMatrixMode (GL_MODELVIEW);
   glLoadIdentity ();
   gluLookAt (0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0);
}
Если прияглядеться, то эта та же иницализация, что расположена у нас в модуле col_gl.cpp
Функция ClearColor устанавливает цвет фона сцены. Первые три параметра - это красная, зеленая и синяя составляющие цвета, а последний лучше оставить 1.0

Подргузите к файлу Unit1.h три заголовочных файла в строго таком порядке:
#include <windows.h>
#include <gl.h>
#include <glu.h>
Теперь посмотрим, что будет, если пользователь изменит размер формы. Тогда ViewPort попрежнему останется со старыми размерами, что нежелательно. Потому перехватим событие OnResize и запишем в него следующий код:
void __fastcall TForm1::FormResize(TObject *Sender)
{
   glViewport (0, 0, Width, Height);

   glMatrixMode (GL_PROJECTION);
   glLoadIdentity ();
   gluPerspective (90, GLdouble(Width/Height), 1.0, 100.0);

   glMatrixMode (GL_MODELVIEW);
}
Мы поставили новый порт просмотра (повторюсь: портом просмотра, грубо говоря, называется прямогульник на форме, в который производится вывод сцены), и после этого изменили проекцию, так как отношение ширины к высоте (aspect) у нас изменились.

Теперь не забудем про деинициализацию, для чего напишем обработчик на событие OnDestroy формы:
void __fastcall TForm1::FormDestroy(TObject *Sender)
{
   if (hGLRC)
   {
      wglMakeCurrent(NULL, NULL);
      wglDeleteContext(hGLRC);
      hGLRC = NULL;
   }
   if (hDC)
   {
      ReleaseDC(Handle, hDC);
      hDC = NULL;
   }
}
Теперь приступим к рисованию. Нарисуем мы квадрат, и создадим простое управление, чтобы можно было обойти квадрат со всех сторон. Кроме того, дадим программе возможность выбирать, какой квадрат показывать: каркасный или закрашенный (GL_LINE_STRIP или GL_QUADS).
Для рисваония будем использовать обработчик события OnIdle у Application. Для этого в конструкторе напишите (куда угодно)
Application->OnIdle = AppIdle;
Здесь AppIdle - это название функции, которая и есть обработчик события.
Событие OnIdle вызывается каждый раз, когда процессор не занят никакими другими заданиями.
Объявите прототип функции AppIdle в файле Unit1.h:
void __fastcall AppIdle (TObject *Sender, bool &Done);

Заведем пять переменных: X, Y, Yaw, Pitch и IsWire. Вот как должен выглядеть файл Unit1.h сейчас:
//---------------------------------------------------------------------------
#ifndef Unit1H
#define Unit1H
//---------------------------------------------------------------------------
#include <Classes.hpp>
#include <Controls.hpp>
#include <StdCtrls.hpp>
#include <Forms.hpp>
#include <windows.h>
#include <gl.h>
#include <glu.h>
//---------------------------------------------------------------------------
class TForm1 : public TForm
{
__published:	// IDE-managed Components
   void __fastcall FormResize(TObject *Sender);
   void __fastcall FormDestroy(TObject *Sender);
   void __fastcall AppIdle (TObject *Sender, bool &Done);
private:	// User declarations
public:		// User declarations
   __fastcall TForm1(TComponent* Owner);

   float X, Y, Yaw, Pitch; // координаты и угол
   bool IsWire; // каркасный ли должен быть куб?
};
//---------------------------------------------------------------------------
extern PACKAGE TForm1 *Form1;
//---------------------------------------------------------------------------
#endif
В конструктор формы добавьте такие строки:
   X = 0; Y = -3;
   Yaw = 0; Pitch = 0;
   IsWire = true;
Теперь в модуле Unit1.cpp добавим тело функции AppIdle:
//---------------------------------------------------------------------------
void __fastcall TForm1::AppIdle(TObject *Sender, bool &Done)
{
   Done = true;

   // Начало обработки мыши
   TPoint* temp;
   temp = new TPoint;
   ::GetCursorPos (temp);
   Yaw += (180 * (temp->x - Screen->Width/2) / Screen->Width);
   Pitch += (180 * (temp->y - Screen->Height/2) / Screen->Height);
   ::SetCursorPos (Screen->Width/2, Screen->Height/2);
   // Конец обработки мыши

   glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

   glLoadIdentity (); // сбрасываем камеру

   // Ставим камеру заново!!!
   gluLookAt (0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0);
   // Вращаем
   glRotatef (Pitch, 1, 0, 0);
   glRotatef (Yaw, 0, 0, 1);
   // Смещаем
   glTranslatef (-X, -Y, 0.0f);

   // ******  рисуем  ******* //

   if (IsWire) glBegin (GL_LINE_STRIP);
   else glBegin (GL_QUADS);

   {  // Нижняя стенка
      glColor3f (1, 0, 0);
      glVertex3f (-1, -1, -1);
      glColor3f (0, 1, 0);
      glVertex3f (-1, 1, -1);
      glColor3f (0, 0, 1);
      glVertex3f (1, 1, -1);
      glColor3f (1, 1, 0);
      glVertex3f (1, -1, -1);
   }
   glEnd ();

   if (IsWire) glBegin (GL_LINE_STRIP);
   else glBegin (GL_QUADS);
   {  // Верхняя стенка
      glColor3f (1, 0, 0);
      glVertex3f (-1, -1, 1);
      glColor3f (0, 1, 0);
      glVertex3f (-1, 1, 1);
      glColor3f (0, 0, 1);
      glVertex3f (1, 1, 1);
      glColor3f (1, 1, 0);
      glVertex3f (1, -1, 1);
   }
   glEnd ();

   if (IsWire) glBegin (GL_LINE_STRIP);
   else glBegin (GL_QUADS);
   {  // Правая стенка
      glColor3f (1, 0, 0);
      glVertex3f (1, -1, -1);
      glColor3f (0, 1, 0);
      glVertex3f (1, 1, -1);
      glColor3f (0, 0, 1);
      glVertex3f (1, 1, 1);
      glColor3f (1, 1, 0);
      glVertex3f (1, -1, 1);
   }
   glEnd ();

   if (IsWire) glBegin (GL_LINE_STRIP);
   else glBegin (GL_QUADS);
   {  // Левая стенка
      glColor3f (1, 0, 0);
      glVertex3f (-1, -1, -1);
      glColor3f (0, 1, 0);
      glVertex3f (-1, 1, -1);
      glColor3f (0, 0, 1);
      glVertex3f (-1, 1, 1);
      glColor3f (1, 1, 0);
      glVertex3f (-1, -1, 1);
   }
   glEnd ();

   if (IsWire) glBegin (GL_LINE_STRIP);
   else glBegin (GL_QUADS);
   {  // Передняя стенка
      glColor3f (1, 0, 0);
      glVertex3f (-1, 1, -1);
      glColor3f (0, 1, 0);
      glVertex3f (1, 1, -1);
      glColor3f (0, 0, 1);
      glVertex3f (1, 1, 1);
      glColor3f (1, 1, 0);
      glVertex3f (-1, 1, 1);
   }
   glEnd ();

   if (IsWire) glBegin (GL_LINE_STRIP);
   else glBegin (GL_QUADS);
   {  // Задняя стенка
      glColor3f (1, 0, 0);
      glVertex3f (-1, -1, -1);
      glColor3f (0, 1, 0);
      glVertex3f (1, -1, -1);
      glColor3f (0, 0, 1);
      glVertex3f (1, -1, 1);
      glColor3f (1, 1, 0);
      glVertex3f (-1, -1, 1);
   }

   glEnd ();

   glFlush ();
   SwapBuffers(hDC);
}
//---------------------------------------------------------------------------
Фигурные скобки при рисовании вертексов написаны просто для украшения кода. Их можно и не писать.
Здесь не очень удачно подрбраны цвета, то есть в вершинах на разных гранях они различны.
Но для примера сойдет (даже лучше).

Обратите внимание, как мы проверяем мышь! Мы берем ее текущие координаты, проверям, насколько они далеко от центра экрана, и на столько изменяем соответствующий угол. После чего ставим мышь в центр экрана. Думаю, многие из вас давно хотят узнать, как изменить текущие координаты мыши. Вот вам и ответ.

Мы поворачиваем сцену командами glRotate*. Сначала на угол Pitch вокруг вектора 1, 0, 0 (вектора, направленного влево). Угол Pitch - это угол наклона (тангажа).
Потом поворачиваем сцену на угол Yaw вокруг вектора 0, 0, 1 (вектора вверх), это угол поворота, то есть направления.

Затем мы смещаем сцену на наши координаты, но на самом деле получается ощущение, будто это не сцена смещена, а мы смотрим на нее несмещенную со своей точки.

В конце две команды:
glFlush () - закончить рисование. В данном случае можно обойтись и без нее.
SwapBuffers () - поменять местами два видеобуфера.

В каждый момент времени мы имеем два видеобуфера. Один из них отображается на экране, а на втором мы рисуем. Если б мы рисовали прямо на экране, то получился бы плохой эффект: объекты, выводимые первыми были бы на экране больше по времени, чем выводимые последними, так как первые выводятся сразу после очистки экрана, а последние, надо полагать, почти перед очисткой. Это дает нежелательный эффект для глаз человека.

Очистить буфер можно командой glClear, сообщив ей, что вы хотите удалить. Мы используем два параметра:
GL_COLOR_BUFFER_BIT - каждый пиксель красится в цвет, определенный командой glClearColor (мы определили черный).
GL_DEPTH_BUFFER_BIT - для каждого пикселя сбрасывается информация о его удалении (очищается Z-буфер)

Можете запустить и посмотреть на результат. Основа программы дописана. Но давайте придадим ей живости!
Мы позволим гулять вокруг этого кубика. Зададим для этой цели два управления: одно для любителей 3D Action, другое для всех остальных.
Итак, для начала первое. Оно будет следующим:
Y - идти вперед.
H - идти назад.
J - стрейф вправо.
G - стрейф влево.
Второе:
Клавиша влево - повернуть влево
Клавиша вправо - повернуть вправо
Клавиша вверх - идти вперед
Клавиша вниз - идти назад

Вращение будет осуществляться мышью.

В модуле Unit1.h добавьте строку:

#include <math.h>

Нам предстоит поработать с тригономтерией.
Благо, мышь у нас уже обработана. Осталась клавиатура. Добавим обработчик события OnKeyDown:
//---------------------------------------------------------------------------
void __fastcall TForm1::FormKeyDown(TObject *Sender, WORD &Key,
      TShiftState Shift)
{
   float FSIN = sin (Yaw*M_PI/180.0f);
   float FCOS = cos (Yaw*M_PI/180.0f);

   if (Key == VK_ESCAPE) Application->Terminate ();
   else if (Key == VK_LEFT) Yaw -= 3;
   else if (Key == VK_RIGHT) Yaw += 3;
   else if (Key == VK_UP) {X += 0.05 * FSIN; Y += 0.05 * FCOS;}
   else if (Key == VK_DOWN) {X -= 0.05 * FSIN; Y -= 0.05 * FCOS;}

   else if (Key == 'G') {X -= 0.05 * FCOS; Y += 0.05 * FSIN;}
   else if (Key == 'J') {X += 0.05 * FCOS; Y -= 0.05 * FSIN;}
   else if (Key == 'Y') {X += 0.05 * FSIN; Y += 0.05 * FCOS;}
   else if (Key == 'H') {X -= 0.05 * FSIN; Y -= 0.05 * FCOS;}

   else if (Key == VK_SPACE) IsWire = !IsWire;
}
//---------------------------------------------------------------------------
Тут все не очень сложно. Ну, думаю, что делает ESC, понятно всем: это выход.
Не очень сложно все и с клавишами LEFT и RIGHT. Они просто увеличивают угол Yaw. Угол Pitch мы увеличивать с клавиатуры не будем.
Остальные клавиши управляют координатами. Клвиши UP и Y, равно как и DOWN и H равнозначны. Так что разберем просто команды, которые мы задали на буквы.
Тут все просто. Они изменяют координаты нашего человечка (камеры). Просто изменять, скажем, координату Y, если мы будем, например, двигаться вперед, нельзя, так как если мы, скажем, смотрим немного в сторону, то изменится и наша координата X. Эта формула и направлена на то, чтоб это все учесть.
Пробелом мы переключаемся между режимами показа каркасного куба и закрашенного.
Теперь запускайте программу и любуйтесь! Все должно работать. Если что-то не работает, вот готовый исходный код:.
draw.zip