ПРЕДИСЛОВИЕ
Глава 1. КООРДИНАТЫ И ИХ ПРЕОБРАЗОВАНИЯ
Отражение
Перенос
Аффинные преобразования в R3
Масштабирование
Отражение
Перенос
Однородные координаты
Системы координат
Задание ориентации
Кватернионы
Проектирование
Перспективное проектирование
Глава 2. УДАЛЕНИЕ НЕВИДИМЫХ ПОВЕРХНОСТЕЙ
Метод трассировки лучей
Метод z-буфера
Алгоритмы упорядочения
Метод двоичного разбиения пространства
Метод порталов
Глава 3. ПРОСТЕЙШИЕ ГЕОМЕТРИЧЕСКИЕ АЛГОРИТМЫ И СТРУКТУРЫ
Нахождение расстояния от точки до прямой
Ограничивающие тела
Проверка пересечения луча с многоугольником
Проверка пересечения двух многоугольников
Иерархические структуры
Область видимости
Глава 4. ОСНОВЫ БИБЛИОТЕКИ OpenGL
Рисование геометрических объектов
Рисование точек, линий и многоугольников
Преобразование объектов в пространстве. Камера
Дисплейные списки
Работа с z-буфером
Задание моделей закрашивания
Освещение
Полупрозрачность. Использование а-канала
Вывод битовых изображений
Ввод-вывод цветных изображений
Наложение текстуры
Управление наложением текстуры
Работа с буфером трафарета
Сохранение параметров
Глава 5. ОБЪЕКТНАЯ МОДЕЛЬ. ОСНОВНЫЕ КЛАССЫ
Глава 6. ОСНОВНЫЕ КЛАССЫ ДЛЯ РЕНДЕРЕРА. РАБОТА С РЕСУРСАМИ
Класс Texture
Класс ResourceManager
Схема \
Класс Timer
Класс Camera
Метод порталов
Работа с полупрозрачными гранями
Консоль
Обработка столкновений
Глава 10. РАБОТА С КАРТАМИ ОСВЕЩЕННОСТИ
Глава 11. ПИШЕМ РЕНДЕРЕР УРОВНЕЙ QUAKE II
Глава 12. ДОБАВЛЯЕМ ЭФФЕКТЫ
Объемный туман
Микрофактурные текстуры
Системы частиц
Хало, блики на линзах
Глава 13. ДОБАВЛЯЕМ МОДЕЛИ
Шейдеры
НАПУТСТВИЕ
Приложение. ВЕКТОРНАЯ И МАТРИЧНАЯ АЛГЕБРА
ЛИТЕРАТУРА
ИСТОЧНИКИ В ИНТЕРНЕТЕ
Текст
                    А. В. Боресков
ГРАФИКА ТРЕХМЕРНОЙ КОМПЬЮТЕРНОЙ ИГРЫ НА ОСНОВЕ OPENGL

МОСКВА  "ДИАЛОГ-МИФИ"  2004

УДК 681.3 Б82 Боресков А. В. Б82 Графика трехмерной компьютерной игры на основе OpenGL. - М.: ДИАЛОГ-МИФИ, 2004. - 384 с. ISBN 5-86404-190-4 Книга посвящена основам программирования трехмерной графики в играх. В ней подробно рассматривается написание графическою ядра для трехмерной игры, позволяющей в реальном времени перемещаться по заданной сцене. Достаточно подробно рассматриваются математические вопросы работы с координатными пространствами, преобразования и проектирование. Также приводится ряд геометрических алгоритмов для решения типовых задач и оптимизации. В книге подробно рассматривается организация работы с ресурсами, включая загрузку как текстур в ряде форматов (bmp. Jpg, png, gif, tga, wal, pcx), так и загрузку трехмерных моделей (ase, md2, md3). Рассмотрение материала сопровождается примерами на языке C++ (для среды MS Visual C++ 6) и UML-диаграммами. Весь исходный код для книги доступен в Интернете по адресу www.steps3d.narod.ru. Учебно-справочное издание Боресков Алексей Викторович Графика трехмерной компьютерной игры на основе OpenGL Редактор О. А. Голубев Корректор В. С. Кустов Макет И. М. Чумаковой Лицензия ЛР N 071568 от 25.12.97. Подписано в печать 20.02.2004. Формат 60x84/16. Бум. офс. Печать офс. Гарнитура Таймс. Усл. печ. л. 22.32. Уч.-нзд. л. 11.79. Тираж 3 000 экз. Заказ 401 ЗАО “ДИАЛОГ-МИФИ”, ООО “Ди М” 115409, Москва, ул. Москворечье, 31, корн. 2 Т.: 320-43-55, 320-43-77 Http://www.bilcx.ru/~<lialog. E-mail: dialog@bitex.ru Подольская типография 142100, г. Подольск, Московская обл., .ул. Кирова. 25 ISBN 5-86404-190-4 © Боресков А. В.. 2004 © Оригинал-макет, оформление обложки ООО “Д и М”. 2004
ПРЕДИСЛОВИЕ Книга, которую вы держите в руках, посвящена такой захватывающей теме, как написание компьютерных игр. В ней подробно рассматривается написание графического ядра трехмерной игры типа Quake. В книге изучаются как математические вопросы работы с трехмерным пространством, так и чисто программные вопросы реализации рассматриваемых алгоритмов. Рассматриваются организация ввода-вывода с использованием популярных библиотек OpenGL (в том числе и работа с библиотекой glut) и Directlnput, работа с ресурсами (текстурами, моделями и т. п.), реализация ряда специальных эффектов, таких, как системы частиц, блики на линзах, объемный туман и многие другие. У читателя предполагается знакомство с алгеброй в объеме средней школы, ряд необходимых дополнительных понятий объясняется в приложении. Кроме того, требуется владение языком C++ в объеме, достаточном для понимания классов и работы с ними. Весьма полезным читателю окажется знакомство с приемами объектно-ориентированного программирования и паттернами проектирования. Для иллюстрации вводимых классов и отношений между ними используются С/Л/Ь-диаграммы. Исходные тексты всех программ, рассматриваемых в этой книге, вы можете найти на компакт-диске, который можно приобрести в издательстве "Диалог-МИФИ" или скачать в Интернете по адресу www.steps3d.narod.ru. По этому же адресу вы сможете найти примеры сцен и другие полезные ресурсы. Первая глава книги посвящена работе с координатами в трехмерном пространстве и их преобразованиям. Рассматриваются однородные координаты и их использование для задания преобразований и проектирования. Вводится понятие кватернионов и объясняется их использование для задания ориентации объектов в трехмерном пространстве. Вторая глава посвящена удалению невидимых поверхностей (определению видимости) в задачах трехмерной компьютерной графики. В ней вводятся основные понятия и рассматриваются как базовые методы (метод трассировки лучей, метод г-буфера, метод художника), так и весьма продвинутые (использование BSP-деревьев, метод порталов, вычисление и использование множеств потенциальновидимых граней). Третья глава посвящена основным алгоритмам трехмерной графики. В ней рассматривается определение пересечения тел, классификация тел /nwof/imon 3
относительно заданной плоскости или пирамиды видимости, использование основных видов ограничивающих тел, методы оптимизации. Все необходимые классы вводятся по ходу изложения. В четвертой главе даются основы библиотеки OpenGL и приводятся примеры ее практического использования. В ней также Содержится руководство по использованию библиотеки glut для организации взаимодействия с оконной системой. В пятой главе вводится используемая в дальнейшем объектная модель и определяются основные классы, служащие для работы со строками и контейнерами, системным логом и файлами конфигурации. Шестая глава полностью посвящена работе с ресурсами игры. В качестве таких ресурсов могут выступать: описание сцен, текстуры, модели и т. п. В этой главе вводятся основные классы, позволяющие легко работать с большим количеством различных типов ресурсов (jpg, bmp, tga, gif, wat, png и др.). В последующих трех главах (седьмая, восьмая и девятая) строится простейший рендерер трехмерных сцен, основанный на портальной модели. По ходу изложения в него добавляются поддержка полупрозрачных граней и зеркал, порталов с преобразованиями, обработка столкновений наблюдателя с объектами сцены. Добавляется консоль, аналогичная используемой в игре Quake. Десятая глава посвящена созданию и использованию карт освещенности. Здесь в построенный ранее рендерер вводятся необходимые изменения, позволяющие автоматически строить и использовать карты освещения для получения более реалистично выглядящих сцен. В одиннадцатой главе показывается, как на основе введенных ранее классов можно построить рендерер уровней из игры Quake II. Построенный рендерер поддерживает загрузку уровней прямо из раЛ-файлов игры. В последних двух главах (двенадцатая и тринадцатая) вводятся различные специальные эффекты (небо, объемный туман, системы частиц, блики йа Линзах), а также рассматривается работа с моделями на примере файлов в форматах ase, md2 и md3. Вводятся многопроходные шейдеры, близкие к используемым в игре Quake III Arena. Напутствие содержит заключительное слово о данной книге и краткий список тем, которые планируется включить в следующую книгу.
Глава 1. КООРДИНАТЫ И ИХ ПРЕОБРАЗОВАНИЯ Одним из самых фундаментальных понятий, которые понадобятся при написании любой трехмерной игры, являются координаты и работа с ними. В этой главе мы рассмотрим работу с преобразованиями, в том числе и с преобразованиями координат. Также будут рассмотрены различные способы задания ориентации и проектирование. Рассмотрим линейное пространство L над полем вещественных чисел (далее будем считать, что L = R2 или L = Я3) и пусть задано отображение (1.1) Определение. Отображение f называется линейным, если для всех вещественных Лири всех векторов х, у е L выполняется следующее равенство (1.2): /(Лх + цу) = Л/(х) + ц/(у). (1-2) Произвольное линейное преобразование (1.1) в R1 можно представить в следующем виде: у = Мх, (1.3) где М = (1.4) /Пц /п12 щ21 м22 матрица размера 2x2 из вещественных чисел. Аналогично произвольное линейное преобразование в R3 задается матрицей 3x3: (1.5) /л,, "Чз М = m2i т22 m2i /Пзз Преобразование (1.3) называется невырожденным, если определитель матрицы (1.4, 1.5) этого преобразования отличен от нуля (det М £ 0). JWIOC/llllOn 5
Основные преобразования в R2 Рассмотрим сначала основные аффинные отображения в двухмерном пространстве. Поворот Поворот вокруг начала координат О (0,0) на угол ср против часовой стрелки (рис. 1.1) задается следующей формулой: Уг СОБф 5Шф Рис. 1.1 Заметим, что Я-1(ф) = Яг(ф)=7?(-ф). (1.7) Обратите внимание, что определитель матрицы преобразования (1.6) равен единице. Таким образом, поворот - это невырожденное линейное преобразование. Растяжение-сжатие (масштабирование) Растяжение или сжатие вдоль координатных осей (рис. 1.2) задается следующей формулой: >л=р. 0> ^J"l° и, где коэффициенты X > 0, ц > 0. (1.8)
Рис. 1.2 В случае, когда Л > 1, ц > 1, происходит растяжение, а в случае Л < 1, ц < 1- сжатие. Как видно из (1.8), это тоже линейное невырожденное преобразование (его определитель равен Лц). Отражение Отражение относительно оси абсцисс (рис. 1.3) задается формулой О О -1 (1.9) И* Аналогично отражение относительно оси ординат задается формулой У. О °Yxi 1 (1.10) Рис. 1.3 Преобразование отражения (1.9, 1.10) также невырожденное линейное преобразование, его определитель равен -1.
Перенос Преобразование переноса (рис. 1.4) на вектор а задается формулой, (1.11) Рис. 1.4 Как легко видеть, преобразование переноса (1.11) не является линейным И линейные преобразования и преобразование переноса являются представителями класса аффинных преобразований. Определение. Отображение f называется аффинным, если его можно представить в виде у = Мх + а, (1-12) где ае L. Можно показать, что любое невырожденное аффинное преобразование вида (1.12) можно представить как суперпозицию (произведение) приведенных выше элементарных преобразований. Пример. Рассмотрим отражение относительно произвольной прямой в пространстве R2. Первым шагом будет преобразование Т(а) переноса на такой вектор а, чтобы рассматриваемая прямая проходила через начало координат. Следующим шагом будет преобразование поворота вокруг начала координат /?(<р), переводящее прямую в ось Ох. После этого мы выполняем отражение МОхотносительно оси Ох, а потом выполняем поворот на угол -ср и перенос иа -а (рис. 1.5).
Таким образом, интересующее иас преобразование можно записать в виде следующей суперпозиции (т. е. последовательного применения) элементарных преобразований: А где Рис. 1.5 Для работы с двухмерными векторами введем класс Vector2D, описываемый ниже. а class Vector2D { public: float x, у;
Vector2D () {} Vector2D ( float рх, float ру ) { х = рх; у = ру; } Vector2D ( const Vector2D& v ) { x = v.x; У = v.y; } Vector2D& operator = ( const Vector2D& v ) { x = v.x; У = v.y; return *this; } Vector2D operator + () const { return *this; } Vector2D operator - () const { return Vector2D ( -x, -y ); } Vector2D& operator += ( const Vector2D& v ) ( x += v.X; У += v.y; return *this; } Vector2D& operator -= ( const Vector2D& v ) { x -= v.x; У -= V.y; return *this; }
Vector2D& operator *= i { x *= v.x; у * = v.y; return *this; } ( const Vector2D& v ) Vector2D& operator *= । ( float f ) { x *= f; у *= f; return *this; } Vector2D& operator /= ( const Vector2D& v ) { x /= v.x; У /= v.y; return *this; } Vector2D& operator /= ( float f ) { х /= f; У /= f; return *this; } float& operator [] ( int index ) { return * ( index + &x ); } intoperator == ( const Vector2D& v ) const { return x == v.x && у == v.y; } intoperator != ( const Vector2D& v ) const { return x != v.x || у != v.y; }
operator float * () { return &х; } operator const float * () const { return &x; } float length () const { return (float) sqrt ( x * x + у * у ); } float lengthSq () const { return x * x + у * y; } Vector2D& normalize () { return (*this) /= length (); } float maxLength () const { return max2 ( (float) fabs (x), (float) fabs (y) ); } float distanceToSq ( const Vector2D& p ) const { return sqr ( _x - p.x ) + sqr ( у - p.y ); } float distanceTo ( const Vector2D& p ) const { return (float) sqrt ( sqr ( x - p.x ) + sqr ( у - p.y ) ); } Vector2D ort () const { return Vecfor2D ( -y, x ); }
friend Vector2D operator + ( const Vector2D&, const Vector2D& ); friend Vector2D operator - ( const Vector2D&, const Vector2D& ); friend Vector2D operator * ( const Vector2D&, const Vector2D& ); friend Vector2D operator * ( float, const Vector2D& ); friend Vector2D operator * ( const Vector2D&, float ); friend Vector2D operator I ( const Vector2D&, float ); friend Vector2D operator I ( Const Vector2D&, const Vector2D& ); friend float operator & ( const Vector2D&, const Vector2D& ); private: float max2 ( float a, float b ) const { return a > b ? a : b; } float sqr ( float x ) const { return x*x; } }; Для поддержки матриц линейных преобразований в двухмерном пространстве мы будем использовать следующий класс: Ы class Matrix2D { public: float x [2] [2] ; Matrix2D () {} Matrix2D ( float ); Matrix2D ( const Matrix2D& ); Matrix2D& operator = ( const Matrix2D& ); Matrix2D& operator = ( float ); Matrix2D& operator += ( const Matrix2D& ); Matrix2D& operator -= (' const Matrix2D& ); Matrix2D& operator *= ( const Matrix2D& ); Matrix2D& operator *= ( float ); Matrix2D& operator /= ( float ); const float * operator [] ( int i ) const { return & x[i][0]; }
float * operator [] ( int i ) ( return & x[i][0]; } ' Matrix2D& invert (); Matrix2D& transpose (); float det () const { return x [0] [0] * x [1] [1] - x [0] [1] * x [1] [0J; } Matrix2D getlnverse () const { return Matrix2D ( *this ).invert (); } static Matrix2D getldentityMatrix (); static Matrix2D getScaleMatrix ( const Vector2D& ); static Matrix2D getRotateMatrix ( float ); static Matrix2D getMirrorXMatrix (); static Matrix2D getMirrorYMatrix () ; friend Matrix2D operator + ( const Matrix2D&, const Matrix2D& ); friend Matrix2D operator - ( const Matrix2D&, const Matrix2D& ); friend Matrix2D operator * ( const Matrix2D&, const Matrix2D& ); friend Matrix2D operator * ( const Matrix2D&, float ) friend Matrix2D operator * ( float, const Matrix2D& ) friend Vector2D operator * ( const Matrix2D&, }; . const Vector2D& ); Удобно создать также класс для работы с произвольными аффинными преобразованиями. Ы class Transform2D { public: Matrix2D m; // linear transform matrix Vector2D v; // translation vector
Transform2D () {} Transform2D ( const Matrix2D matrix, const Vector2D& vector ) : m ( matrix ), v ( vector ) {} Transform2D ( const Transform2D& tr ) : m ( tr.m ), v ( tr.v ) {} . Transform2D ( const Matrix2D& matrix ) : m ( matrix ) { v.x = v.y = 0; } Transform2D& invert () { m. invert (); v = m * v; return *this; } Transform2D - getlnverse () const { return Transform2D ( *this ).invert (); } // transform a point in space Vector2D 4transformPoint ( const Vector2D& p ) const { return Vector2D ( v.x + m.x [Q][0]*p.x + + m.x [0][1]*p.y, v.y + m.x [1][0]*p.y + + m.x [1][l]*p.y ); } // transform a direction // can change length Vector2D transformDir ( const Vector2D& p ) const { •' ' ' return Vector2D ( m.x [0][0]*p.x + m.x [0][l]*p.y, m.x [1][0]*p.y + m.x [l][l]*p.y ); void buildHomogeneousMatrix ( float matrix [16] ) const; const Vector2D& getTranslation () const { return v; }
const Matrix2D& getLinearPart () const { return m; } static Transform2D getldentity () { ’ ‘ return Transform2D ( Matrix2D : : getldentityMatrix () ) ; } ' static Transform2D getTranslate ( const Vector2D& v ) { return Transform2D ( Matrix2D :: getldentityMatrix (), v ); } static Transform2D getScale ( const Vector2D& s ) { return Transform2D ( Matrix2D : : getScaleMatrix ( s ) ) ; } static Transform2D getScale ( float factor ) { return Transform2D (Matrix2D :: getScaleMatrix(Vector2D(factor, factor))) ; } static Transform2D getRotate ( float angle ) { return Transform2D ( Matrix2D :: getRotateMatrix ( angle ) ); } Аффинные преобразования в R3 Рассмотрим теперь аффинные преобразования в трехмерном пространстве. Как и ранее, рассмотрение начнем с основных видов преобразований -поворота, масштабирования, отражения и переноса. Поворот Преобразование поворота в общем случае задается при помощи матрицы R , удовлетворяющей условию (1.7). Из этого соотношения вытекает RRT=RTR = I. (1.13)
Несложно выписать матрицы поворота вокруг координатных осей. Матрица поворота вокруг оси Ох на угол ф имеет вид <1 о о Л Мф) = О совф -втф О втф сояф (1.14) Матрица поворота вокруг оси Оу на угол ф имеет вид ' СОЯф 0 81Пф - я,(ф)= 0 1 0 (1.15) -БШф 0 СОвф а матрица поворота вокруг оси Oz - СОвф -БШф О' Мф) = ЭЩф СОвф 0 (1.16) 0 0 1 Поворот вокруг прямой, проходящей через начало координат и заданной направляющим вектором I на угол ф против направления часовой стрелки, можно представить в виде /?(ф) = / + S sin ф + S2 (1 — совф), (1.17) где 5 = '0 /3 < Ч о /, -1. о 1 2 1 J (1.18) Масштабирование i <?' : : 1' - Ч" ЧхЧ Иф Масштабирование вдоль координатных осей задастся при помощи мат- рицы s = 'к 0 0 О' И 0 (i.i9) 0 0 v где коэффициенты X, ц, v > 0.
Отражение Отражение относительно плоскости Оху задается матрицей 1 0 0' 0 1 0 0 0-1 к ) (1.20) Отражение относительно остальных координатных плоскостей задается аналогично. Перенос Преобразование переноса на вектор а в пространстве R3 задается формулой у = х + а . (1-21) По аналогии с классами Vector2D, Matrix2D и Transform2D удобно ввести соответствующие классы для работы в трехмерном пространстве. Их полный исходный код находится на компакт-диске. Однородные координаты Удобно представить все аффинные преобразования в матричном виде. С этой целью вводятся так называемые однородные координаты. Каждому вектору х = (хр х2, x3)r е R3 можно поставить в соответствие вектор х = (хр х2, х3,1)г е/?4. При этом в пространстве R* векторов вида (хрХ2,х3, w)r можно произвести факторизацию, поставив в соответствие произвольному вектору (хр х2, х3, w)7, w * 0 вектор (xt lw,x2l w, х3 / w, l)7. ТбЙа любой прямой, проходящей через началд координат и состоящей из точек вида (wxp wx2, wx3, w)7, будет поставлен в соответствие вектор (хр х2, х3,1)7. Тем самым множество векторов R*\{0} факторизуется на классы эквивалентности, где каждый такой класс представлен вектором вида (Хр х2, х3,1)7. Такие четырехмерные векторыс вводимым описанным выше отношением эквивалентности называются однородными координатами.
Рассмотрим преобразования в пространстве однородных координат. Определение. Матрица Н = 1 < i, j < 4 называется матрицей од- нородного преобразования, если = 1. Произвольное однородное преобразование Н можно записать в следующем виде: [М а-! Я= Т (1.22) Ьт 1 где М - матрица 3x3, а а и b - трехмерные векторы. Произвольному линейному преобразованию (1.3) в трехмерном пространстве соответствует следующее однородное преобразование: М О' 0г 1 (1.23) Сдвиг на вектор а можно также представить при помощи однородного преобразования I а 1 (1.24) Таким образом, произвольное аффинное преобразование в трехмерном пространстве у = Мх + а (1.25) можно, используя матрицы однородных преобразований, записать в следующем виде: Если есть однородная матрица аффинного преобразования Н, то обратное преобразование определяется следующей матрицей: М аТ'_ГМ'' ~м~'а 0г 1 ” 0г 1 (1.27) Одним из преимуществ записи аффинных преобразований через однородные матрицы заключается в том, что суперпозиции преобразований соответствует перемножение соответствующих матриц. Обратному преобразованию соответствует обратная матрица.
Системы координат Одна и та же точка в трехмерном пространстве может быть задана при помощи различных систем координат, и поэтому часто возникает необходимость перехода от одной системы координат к другой. Система координат в трехмерном пространстве определяется своим началом р и тремя базисными линейнонезависимыми векторами , е2 и е3 (рис. 1.6). Далее эти векторы мы будем считать ортонормиро-ванными (т. е. их длины равны единице и они ортогональны друг другу). Базисные векторы е,,е2 и е3 можно записать как столбцы матрицы * = ki е2 е3]. (1.28) В силу ортогональности базисных векторов еГе2 и е3 матрица (1.28) является ортогональной, т. е. обладает следующими свойствами: R-' = RT, (1.29) |det/?| = l (1.30) Координатная система называется правосторонней, если det R = 1, и левосторонней, если det R = -1. Базисные векторы правосторонней системы кординат удовлетворяют следующим соотношениям: е1=е1Хе3, е2 = е3Хе1, е3=е2хег (1-31) В стандартной системе координат р = (0,0,0)Г, е,=(1,0,0)Г, е2=(0,1,0)\ е3=(0,0,1)Г. (1.32) Если заданы координаты вектора х в одной системе координат, то можно получить его координаты х' в другой системе координат. Его координаты (x(, х2, х3 / удовлетворяют следующим соотношениям: х = р + х^е, + х2е2 + х3е3 = р + Rx'. (1.33)
Отсюда легко получить х = R~' (х-р) = R1 (х-р). (1-34) Тогда для перевода в новую систему координат можно использовать однородное преобразование, задаваемое следующей матрицей: Rr -р О' 1 (1.35) Задание ориентации При работе с объектами в трехмерном пространстве постоянно возникает задача задания ориентации объекта. Одним из способов задания ориентации является задание при помощи ортогональной матрицы, однако такой способ очень неудобен, поэтому обычно используются другие способы. Одним из самых простых способов задания ориентации объекта является использование так называемых углов Эйлера - именно Эйлером впервые было наказано, что из одной произвольной ориентации в трехмерном пространстве можно перейти в любую другую при помощи не более трех поворотов. Для этого выбираются три оси. Обычно в качестве этих осей выбирают отрицательное направление оси Oz и оси Ох и Оу (рис. 1.7). Тогда произвольная ориентация может быть задана при помощи последовательных поворотов вокруг каждой из этих осей.
R(y,p,r)=R.z(r)Rx(p)R>.(y) (1.36) Обычно используемые углы называются roll (крен), pitch (тонгаж) и yaw (рысканье). Поскольку R является произведением ортогональных матриц, то и сама она также является ортогональной в силу следующего равенства: R1 =(/?_. К, 7?,. )' =7?;‘7?;17?:! = R[RXR[Z = (R_zRxR,)1 =RJ. (1.37) Рассмотрим, каким образом можно по ортогональной матрице найти углы Эйлера. Для этого распишем формулу (1.36) поэлементно: ( cvc: -sxsvsz ~cxs_ CZSY + SYSXS. R = R_.RXR, = c.sxsv +Cv.V, CXCZ -cvczsx + sYsz sx CC, k x y * •' 7 (1-38) где сЛ = cos р, sx = sin р, cv = cos у, sv = sin у, с. = cos (-г), S. = sin (-г). (1-39) Отсюда легко получаем р = arcsinr,,. Тогда для нахождения углов Эйлера можно воспользоваться следующим фрагментом кода: Е1 pitch = asin ( r21 ); if ( pitch < M_PI / 2 ) { if ( pitch > -M_PI I 2 ) { roll = -atan2 ( -rOl, rll ); yaw = atan2 ( -r20, r22 ); } else { roll = atan2 ( ro2, rOO ); yaw = 0; } }
else { roll = atan2 ( ro2> rOO ) ; yaw = 0; Однако при использовании углов Эйлера может произойти неприятное явление, называемое gimbal lock. При этом происходит потеря одной из имеющихся степеней свободы. Рассмотрим, например, случай, когда р = . В этом случае матрица поворота (1.38) примет следующий вид: Ф’%’г) = ^cos(y + г) sin(y + г) О О sin (у + г) О -cos(y + r) 1 О (1-40) Таким образом, матрица поворота (1.40) зависит только от суммы двух углов и одна степень свободы была утеряна, т. е. поворот вокруг оси Оу приведет к кому же, к чему приводит и поворот вокруг оси -Oz на тот же угол. Эйлером также было показано, что из одной ориентации в трехмерном пространстве можно всегда прийти к любой другой путем одного поворота вокруг определенной оси (зависящей от обеих этих ориентаций). Таким образом, произвольная ориентация может быть представлена как угол и ось поворота, т. е. при помощи четырех чисел. Однако непосредственное использование такого представления (как и применение углов Эйлера) для работы с ориентациями в трехмерном пространстве (что постоянно возникает в задаче анимации) весьма затруднительно. Для этого обычно используют весьма простой и элегантный подход, основанный на использовании так называемых кватернионов. Кватернионы Одним из очень удобных средств для представления ориентации являются так называемые кватернионы. Кватернионы впервые были введены еще в 1843 г. Гамильтоном как расширение комплексных чисел, но впервые были использованы в компьютерной графике только в 1985 г.
Кватернион определяется как четырехмерный вектор (и», х, у, z), где все компоненты являются вещественными числами. Иногда по аналогии с комплексными числами для кватернионов используется следующая запись: q = w + xi + yj + zk , (1-41) где i,j, k - мнимые единицы, удовлетворяющие следующим соотношениям: I =j = k =-1, (142) ij = ~ji = k, jk = -kj = i, ki = -ik = j. Вектор xi + yj + zk называется мнимой частью кватерниона, a w - его действительной частью. Сложение и вычитание кватернионов определяются покомпонентно: 91 + Я1 = (»*k + V + Л J + М) + (И'г + x2i + y2j + z2k) = / / , . (1.43) = (w,+iv2)+(x1+x2)j+(y1+yJ)j+(z1 + zI)*:. Умножение кватернионов можно определить, исходя из формул (1.40), следующим образом: = {w\ +*? + yj + ^k)(w2 + x2i + y2j + z2k) = = (H'lwi -ХЛ -yty2 -ztz2)+(vv,x2 + w2xi + ylz1-y2zi)i+ (1.44) +(wty2 + w2yt + x2zt-xlz2)j + (wiz1 + w2zt +xty2 -x2y, )k. Обратите внимание, что умножение кватернионов некоммутативно, т. е. в общем случае qxq2 * q2q (это следует из соотношений (1.40). Для кватерниона q можно определить сопряженный кватернион q', определив его следующим образом: а<7* = (w+xi + yj + zk) =w-xi-yj-zk. (1.45) Для операции сопряжения кватернионов справедливы следующие свойства: (<?*) =<7-(pq)‘ =qp", (p + q^p+q.
Можно определить норму кватерниона следующим образом: N(q) = w2 + х2 + у2 + z2. (1.47) Для введенной таким образом нормы справедливы следующие свойства: .............................................................. W(p<7)=/V(p)/V(<7). (148) Единичным кватернионом называется такой кватернион <?, что Для ненулевого кватерниона q можно ввести понятие обратного кватерниона q~l: qq~' =q~'q = l- (1-49) Для этого заметим, что qq=q'q = N(q). Тогда обратный кватернион можно определить следующим образом: ’-=%(,)• <L50) Для обратного кватерниона выполнены следующие свойства: W'=?V (1.51) Обратите внимание, что для единичного кватерниона обратный к нему совпадает с сопряженным. Кватернион q = w+xi + yj + zk можно также представить в следующем виде: <7 = [и\ v], где v = xi+yj + zk - трехмерный вектор. Если считать v обычным трехмерным вектором, то произведение кватернионов можно представить следующим образом: Ч^г =k.v1][»v2,vj = = [w1w2-v1v2,w1v2 + w2v,+v1xv2].
Также кватернионы можно рассматривать как четырехмерные векторы, т. е. для них можно ввести скалярное произведение -<72 = w,w2+х,х2+ у,у2+ z(z2. (1.53) . Заметим, что единичный кватернион всегда может быть представлен в следующем виде: <7 = [cos 9, v sin 0], (1-54) где длина вектора у равна единице. Обратите внимание, что для единичного кватерниона q справедливо q" =[cos(n0), vsin(«0)] . (1.55) Это позволяет ввести для единичного кватерниона операцию возведения в произвольную вещественную степень следующим образом: q' = [cos(r0), vsin(r0)] . (1.56) Для поворота вектора v с использованием кватерниона запишем этот вектор как кватернион [0, у]. Тогда поворот может быть задан при помощи единичного кватерниона q в соответствии со следующей формулой: v' = q-[0, v]-q~l. (1.57) При этом если q = [cos 0, и sin 0], то поворот осуществляется вокруг оси, задаваемой вектором и на угол 20. Последовательность поворотов соответствует произведению кватернионов, задающих эти повороты. р(<7[О,у]<7'‘)-р'‘=(р<7)[О,у](р<7)‘1. (1.58) Из этого следует, что для поворота вектора на угол 0 вокруг вектора и следует использовать следующий кватернион: <7 = 0 . 0 cos—, wsin— 2 2 (1.59) Обратите внимание, что как кватернион <7 = [w, у], так и кватернион q = |-w, - у] соответствуют одному и тому же повороту.
Для заданного единичного кватерниона q можно построить матрицу 3x3, соответствующую данному повороту. 'l-2(/ + z2) Af(<?)= 2(xy + wz) 2(xz~ wy) 2(xy-wz) 1-2(x2 + z2) 2(yz + wx) 2(xz + wy) 2(yz-wx) l-2(x; + /) (1.60) Также можно по заданной ортогональной матрице М построить соответствующий кватернион. Для этого из (1.57) достаточно выразить (w, х, у, z) через элементы матрицы. Для этого заметим, что сумма диагональных элементов матрицы (ее след) trM всегда равна 3w2, откуда легко находится величина w . Далее производится вычитание элементов, симметричных относительно главной диагонали, друг из друга для получения мнимой части кватерниона, например /Hi 2 -m2 | = -4wz. (1.61) Отсюда легко находится величина z- Таким образом, для определения кватерниона по ортогональной матрице можно использовать следующие формулы: /гмГ W =.-----, V з х = -^-(т3.2-'«2.з), 3’ = 7-(т1.з-тз.1)’ z = 4w 4w 4w (1.62) Для корректного применения формул (1.59) требуется, чтобы w*0. Обычно из соображений численной устойчивости требуют, чтобы |w| >е. Если это не выполнено, то считаем w = 0. Тогда т2 2 -«з.3 = ~2х2 (1-63) в силу того, что строящийся кватернион является единичным. Отсюда находится х и проверяется выполнение условия |х| > е . Если оно выполнено, то «2.1 «3.1 ——, z = ——. 2х 2х (1.64)
Если же |w| < е, |х| < е , то получаем и тогда 2 = ‘ "Х 2 '»3.1 Z =----• 2у (1.65) (1.66) В противном случае считаем w = х = у = О, z = 1. Часто возникает задача плавного перехода от одной ориентации к другой. Если обе эти ориентации заданы при помощи кватернионов, то для получения промежуточных ориентаций удобно использовать так называемую сферическую интерполяцию кватернионов (slerp). Пусть заданы два единичных кватерниона и q2 и вещественный параметр 1 е [0,1]. Тогда для получения промежуточной ориентации между qx и q2, соответствующей параметру t, можно воспользоваться следующей формулой: . sin(<p(l- г)) sin(cpr) з'/егр(<71,<72,/) =-:----—q{ +—------’-q^ (1.67) sincp sincp Формула (1.67) находит единичный вектор, лежащий в плоскости, проходящей через , q2 и начало координат. В этой формуле угол <р определяется следующим образом: coscp = w1w2+x1x2 + y,y2 + z1z2, (1.68) т. е. предстваляет косинус скалярного произведения qx и q2. Существует довольно простая интерпретация сферической интерполяции кватернионов: рассмотрим кватернионы <?, и q2 как точки на единичной сфере |<?|| = 1. Тогда функция slerp описывает кратчайшую дугу на этой сфере, соединяющую эти точки. Ниже приводится описание класса, служащего для работы с кватернионами. X class Quaternion { public: // make all members public float x, y, z, w;
Quaternion () {} Quaternion ( float theX, float theY = 0, float theZ = 0, float theW = 0 ) { x = theX; у = theY; z = theZ; w = theW; } Quaternion ( const Quaternions q ) { x = q.x; у = q-y; z = q. z ; w = q. w; } Quaternion ( const Vector3DS v ) { X = v.x; У = v.y; z = v. z ; W = O.Of; } Quaternion ( float angle, const Vector3DS axis ); Quaternion operator + () const { return *this; } Quaternion operator - () const { return Quaternion ( -x, -y, -z, -w ); } Quaternion& conj () { x = -x; у = -у; z = - z; return *this; }
Quaternions operator += ( { х += q.x; У += Q-У; z += q.z; w += q.w; return *this; } Quaternions operator -= ( { x -= q.x; У -= q-У; z - = q. z ; w -= q.w; return *this; } Quaternions operator *= ( { *this = Quaternion ( у * q. z * q. x * q. w * q. return *this; } Vector3D rotate QuaternionSnormalize QuaternionsinitWithAngles Matrix3D getMatrix void getHomMatrix const Quaternions q ) const Quaternions q ) const Quaternions q ) z - z * q.y + w * q.x + x * q.w, x - x * q.z + w * q.y + у * q.w, У - У * q.x + w * q.z + z * q.w, w-x*q.x-y*q.y-z *q.z ); ( const Vector3DS v ); 0 ; ( float yaw, float pitch, float roll ); () const; ( float * m ) const; Проектирование Важной операцией при работе с трехмерными объектами является проектирование. Проектирование - это преобразование, ставящее точкам из пространства R? в соответствие точки на некоторой плоскости, называемой картинной. В компьютерной графике используются два основных вида проектирования - параллельное и перспективное.
Параллельное проектирование Рассмотрим плоскость л:(и,х) + г/ = 0 в пространстве R3, на которую будет осуществляться проектирование. Пусть также задан вектор I, вдоль которого будет осуществляться проектирование. При этом будем считать, что (/,л) / 0 . Тогда для нахождения проекции произвольной точки х на плоскость л проведем через точку х прямую с направляющим вектором I и точку пересечения этой прямой с плоскостью л и назовем проекцией точки х на плоскость л вдоль направления / (рис. 1.8). Произвольная точка прямой, проходящей через точку х и имеющей направляющий вектор I, имеет вид у = x + tl, где te R. Тогда параметр t точки пересечения этой прямой и плоскости л найти, подставив уравнение прямой (1.69) в уравнение плоскости (х + т/,и) + г/ = 0. Отсюда получаем f : d + (x,n) Зная г, легко находим проекцию точки х по формуле (1.69). (1.69) можно (1-70) (1.71)
Обратите внимание, что параллельное проектирование является аффинным преобразованием и поэтому его можно задать при помощи матрицы однородного преобразования. Приведем матрицу канонического параллельного проектирования, осуществляемого на плоскость Оху вдоль оси Oz . 10 0 0 1 о Р„ -* ООО ООО Несмотря на свою простоту, параллельное проектирование обычно малоприменимо для визуализации сложных трехмерных сцен. Перспективное проектирование Рассмотрим, как и ранее, картинную плоскость л: (и, х) + d = 0. Пусть также задана точка с, которую будем называть центром проектирования. Тогда перспективной проекцией точки х назовем точку пересечения плоскости л с лучом, выходящим из с и проходящим через х при условии, что точка х лежит в положительном полупространстве относительно плоскости л (рис. 1.9). Пусть проекцией точки х является точка у. Тогда, поскольку эта точка лежит на отрезке, соединяющем точки х и с, справедлива следующая фор мула: y = (l-t)c + tx,te [0,1]. (1.73) Тогда из условия принадлежности проекции плоскости (1.70) получаем d + (с,п) (х,п)-(с,и) (1.74)
Как видно из этой формулы, перспективное преобразование не является аффинным преобразованием (на самом деле это преобразование принадлежит к классу дробно-линейных преобразований). Тем не менее это преобразование также можно записать при помощи матрицы однородного преобра-зования. Выпишем каноническое уравнение перспективного проектирования. Пусть центр проектирования равен (О, О, -I)7 , а картинная плоскость задается уравнением z = 1 • Тогда проекцией произвольной точки (х,,х2,х3) будет точка Х2/ 7(хз-!) ,11. Эта преобразование осуществляется при помощи следующей матрицы: fl О О (П (1.75) поскольку (1.76) Таким образом, как произвольное аффинное преобразование, так и параллельное и перспективное проектирование может быть записано при помощи матриц однородных преобразований. Более подробно о координатах и преобразованиях можно прочитать в [1], [13].
Глава 2. УДАЛЕНИЕ НЕВИДИМЫХ ПОВЕРХНОСТЕЙ Одной из очень важных задач при рендеринге сложных трехмерных сцен является определение того, какие части объектов (ребра, грани), находящихся в трехмерном пространстве, будут видны при заданном способе проектирования, а какие будут закрыты от наблюдателя другими объектами. В качестве возможных видов проектирования традиционно рассматриваются параллельное и центральное (перспективное). Плоскость, на которую осуществляется проектирование, будем далее называть картинной (рис. 2.1). Несмотря на кажущуюся простоту, задача удаления невидимых линий и поверхностей является достаточно сложной и зачастую требует очень больших объемов вычислений. Поэтому существует целый ряд различных методов решения этой задачи, включая и методы, опирающиеся на аппарат- ные решения. Рис. 2.2 34 ziiworriwi
Эти методы различаются по следующим основным параметрам (рис. 2.3): • способу представления объектов; • способу визуализации сцены; пространству, в котором проводится анализ видимости; виду получаемого результата (его точности). Рис. 2.3 В качестве возможных способов представления объектов могут выступать аналитические (явные и неявные), параметрические и полигональные. Далее будем считать, что все объекты представлены набором выпуклых плоских граней, например треугольников (полигональный способ), которые могут пересекаться одна с другой только вдоль ребер. Координаты в исходном трехмерном пространстве будем обозначать через (х, у, z), а координаты в картинной плоскости - через (X,У). Будем также считать, что на картинной плоскости задана целочисленная растровая решетка - множество точек (z, j), где in j - целые числа. Если это не оговорено особо, будем считать для простоты, что проектирование либо осуществляется на плоскость Оху параллельно оси Oz , т. е. задается формулами X = х, Y = y, (2.1)
либо является центральным с центром, расположенным на оси Oz и задается формулами Х=-, У=—. (2.2) Z Z Использование этих формул объясняется тем, что посредством невырожденного аффинного преобразования параллельное проектирование всегда может быть сведено к виду (2.1), а произвольное перспективное - к виду (2.2). Существует два различных способа изображения трехмерных тел - каркасное (wireframe - рисуются только ребра) и сплошное (solid - рисуются закрашенные грани). Тем самым возникают два типа задач - удаление невидимых линий (ребер для каркасных изображений) и удаление невидимых поверхностей (граней для сплошных изображений). Анализ видимости объектов можно производить как в исходном трехмерном пространстве, так и на картинной плоскости. Это приводит к разделению методов на два класса: • методы, работающие непосредственно в пространстве самих объектов; • методы, работающие в пространстве картинной плоскости, т. е. работающие с проекциями объектов. Получаемый результат представляет собой либо набор видимых областей или отрезков, заданных с машинной точностью (имеет непрерывный вид), либо информацию о ближайшем объекте для каждого пиксела экрана (имеет дискретный вид). Методы первого класса дают точное решение задачи удаления невидимых линий и поверхностей, никак не привязанное к растровым свойствам картинной плоскости. Они могут работать как с самими объектами, выделяя те их части, которые видны, так и с их проекциями на картинную плоскость, выделяя на ней области, соответствующие проекциям видимых частей объектов, и, как правило, практически не привязаны к растровой решетке и свободны от погрешностей дискретизации. Так как эти методы работают с непрерывными исходными данными и получающиеся результаты не зависят от растровых свойств, то их иногда называют непрерывными (continuous methods). Простейший вариант непрерывного подхода заключается в сравнении каждого объекта со всеми остальными, что дает временные затраты, пропорциональные п2, где п - количество объектов в сцене. Однако следует иметь в виду, что непрерывные методы, как правило, достаточно сложны.
Методы второго класса (point-sampling methods) дают приближенное решение задачи видимости, определяя видимость только в некотором наборе точек картинной плоскости - в точках растровой решетки. Они очень сильно привязаны к растровым свойствам картинной плоскости и фактически заключаются в определении для каждого пиксела той грани, которая является ближайшей к нему вдоль направления проектирования. Изменение разрешения приводит к необходимости полного перерасчета всего изображения. Простейший вариант дискретного метода имеет временные затраты порядка Сп, где С - общее количество пикселов экрана, а п - количество объектов. Всем методам второго класса традиционно свойственны ошибки дискретизации (aliasing artifacts). Однако, как правило, дискретные методы отличаются известной простотой. Кроме этого, существует довольно большое количество смешанных методов, использующих работу как в объектном пространстве, так и в картинной плоскости, методы, выполняющие часть работы с непрерывными данными, а часть с дискретными. Большинство алгоритмов удаления невидимых граней и поверхностей тесно связано с различными методами сортировки. Некоторые алгоритмы проводят сортировку явно, в некоторых она присутствует в скрытом виде. Приближенные методы отличаются друг от друга фактически только порядком и способом проведения сортировки. Очень распространенной структурой данных в задачах удаления невидимых линий и поверхностей являются различные типы деревьев -двоичные (BSP-trees), четверичные (Quadtrees), восьмеричные (Octtrees) и др. Методы, практически применяющиеся в настоящее время, в большинстве являются комбинациями ряда простейших алгоритмов, неся в себе целый ряд разного рода оптимизаций. Крайне важную роль в повышении эффективности методов удаления невидимых линий и граней играет использование когерентности (coherence, связность). Выделяют три типа когерентности: • Когерентность в картинной плоскости. Если данный пиксел соответствует точке грани Р, то скорее всего соседние пикселы также соответствуют точкам той же грани (рис. 2.4). • Когерентность в пространстве объектов. Если данный объект (грань) видим (невидим), то расположенный рядом объект (грань) скорее всего также является видимым (невидимым) (рис. 2.5).
• В случае построения анимации возникает третий тип когерентности -временная. Грани, видимые в данном кадре, скорее всего будут видимы и в следующем. Аналогично грани, невидимые в данном кадре, скорее всего будут невидимы также и в следующем. Фактически применение когерентности заключается в использовании результатов, полученных для одних объектов (пикселов, кадров) для ускорения обработки других. Аккуратное использование когерентности позволяет заметно сократить количество возникающих проверок и заметно повысить быстродействие алгоритма. Методы оптимизации Отсечение нелицевых граней Рассмотрим многогранник, для каждой грани которого задан единичный вектор внешней нормали (рис. 2.6). Несложно заметить, что если вектор нормали грани п составляет с вектором I, задающим направление проектирования, тупой угол (вектор нормали направлен от наблюдателя), то эта грань заведомо не может быть видна (рис. 2.7). Такие грани называются нелицевыми. Если соответствующий угол является острым, грань называется лицевой.
При параллельном проектировании условие на угол можно записать в виде неравенства (»,/)< О, (2.3) поскольку направление проектирования от грани не зависит. При центральном проектировании с центром в точке с вектор проектирования для точки р будет равен I = с- р. (2.4) Для определения того, является ли заданная грань лицевой или нет, достаточно взять произвольную точку р этой грани и проверить выполнение условия (2.3). Знак этого скалярного произведения не зависит от выбора точки на грани, а определяется тем, в каком полупространстве относительно плоскости, содержащей данную грань, лежит центр проектирования. Так как при центральном проектировании проектирующий луч зависит от храни (и не зависит от выбора точки на грани), то лицевая грань может стать нелицевой и наоборот, даже при параллельном сдвиге. При параллельном проектировании сдвиг не изменяет углов и то, является ли грань лицевой или нет, зависит только от угла между нормалью к грани и направлением проектирования. Заметим, что если по аналогии с определением принадлежности точки многоугольнику пропустить через произвольную точку картинной плоскости проектирующий луч к объектам сцены, то число пересечений луча с лицевыми гранями будет равняться числу пересечений луча с нелицевыми гранями. В случае, когда сцена представляет собой один выпуклый многогранник, удаление нелицевых храней полностью решает задачу удаления невидимых граней.
Хотя в общем случае предложенный подход и не решает задачи удаления полностью, но тем не менее позволяет примерно вдвое сократить количество рассматриваемых граней, вследствие того что нелицевые грани всегда невидны; что же касается лицевых граней, то в общей ситуации части некоторых лицевых граней могут быть закрыты другими лицевыми гранями (рис. 2.8). Ребра между нелицевыми гранями также всегда не видны. Однако ребро между лицевой и нелицевой гранями вполне может быть и видимым. Ограничивающие тела (Bounding Volumes) При удалении невидимых линий и поверхностей постоянно возникает необходимость сравнения граней и объектов друг с другом. Такие задачи часто оказываются сложными и трудоемкими. Одним из средств, позволяющих заметно упростить подобные сравнения, является использование так называемых ограничивающих объемов (тел). Опишем вокруг каждого объекта тело достаточно простого вида. Если эти тела не пересекаются, то и содержащиеся внутри них объекты пересекаться не будут (рис. 2.9). Налицо несомненный выигрыш. Следует, однако, иметь в виду, что если описанные тела пересекаются, Рис. 2.10 то сами объекты при этом пересекаться не обязаны (рис. 2.10) В качестве ограничивающих тел чаще всего используются прямоугольные параллелепипеды с ребрами, параллельными координатным осям. Тогда ограничивающее тело (в данном случае его называют bounding box) описывается шестью числами (Xn„r. ’ Утш ’ ^iniii ) * (^“max ’ .Утах * ^max ) ’
где первая тройка чисел задает одну вершину параллелепипеда, а вторая -противоположную. Сами числа представляют собой покоординатные значения минимума и максимума из координат точек исходного объекта. Проверка на пересечение двух тел сводится просто к проверкам на пересечения промежутков [^min ’^“max J’ I -Уmin ’ -Утах j ’ l^min * ^inax 1 одного тела с соответствующими промежутками другого. В случае, если пересечение хотя бы одной пары промежутков пусто, можно сразу заключить, что тела, а следовательно, и содержащиеся внутри них объекты не пересекаются. Ограничивающие тела можно строить и для проекций объектов, причем в случае параллельного проектирования вдоль оси Oz ограничивающим телом для проекции будет прямоугольник, получающийся из ограничивающего тела для самого объекта отбрасыванием z-компоненты. Ограничивающие тела можно описывать не только вокруг отдельных граней, но и вокруг наборов граней и сложных составных объектов, что позволяет легко отбрасывать сразу целые группы граней и объектов. При этом могут возникать сложные иерархические структуры. Подробнее ограничивающие тела рассматриваются в гл. 3. Разбиение пространства(плоскости) (Spatial Subdivision) Еще одним методом, позволяющим заметно облегчить сравнение объектов друг с другом и использовать когерентность как в пространстве, так и на картинной плоскости, является разбиение пространства (картинной плоскости). С этой целью разбиение пространства строится уже на этапе препроцессирования и для каждой клетки разбиения составляется список всех объектов (граней), которые ее пересекают. Простейшим вариантом является равномерное разбиение пространства на набор равных прямоугольных клеток (рис. 2.11). Очень эффективным является использование разбиения картинной плоскости, когда каждой клетке разбиения ставится в соответствие список тех объектов, проекции которых данную клетку пересекают. Для отыскания
всех объектов, которые закрывают рассматриваемый объект при проектировании, определяются объекты, попадающие в те же клетки картинной плоскости, в которые попадает и проекция данного объекта, и на закрывание проверяются только они. Для сцен с неравномерным распределением объектов имеет смысл использовать неравномерное (адаптивное) разбиение пространства или плоскости (рис. 2.12). Иерархические структуры (Hierarchies) При работе с большими объемами данных весьма полезными могут оказаться различные древовидные (иерархические) структуры. Стандартными формами таких структур являются восьмеричные, тетрарные и BSP-деревья, а также деревья ограничивающих тел. Одной из сравнительно простых структур является иерархия ограничивающих тел (Bounding Volume Hierarchy). Сначала ограничивающее тело описывается вокруг всех объектов. На следующем шаге объекты разбиваются на несколько компактных групп и вокруг каждой из них описывается свое ограничивающее тело. Далее каждая из групп снова разбивается на подгруппы, вокруг каждой из них строится ограничивающее тело и т. д. В результате получается дерево, корнем которого является тело, описанное вокруг всей сцены. Тела, построенные вокруг первичных групп, образуют первичных потомков, вокруг вторичных - вторичных и т. д. Сравнения объектов начинаются с корня. Если сравнение не дает положительного ответа, то все тела можно сразу отбросить. В противном случае проверяются все его прямые потомки, и если какой-либо из них не дает положительного ответа, то все объекты, содержащиеся в нем, сразу же отбрасываются. При этом уже на ранней стадии проверок отсечение основного количества объектов происходит достаточно быстро, ценой всего лишь нескольких проверок. Иерархические структуры можно строить и на основе разбиения пространства (картинной плоскости): каждая клетка исходного разбиения разбивается на части (которые, в свою очередь, также могут быть разбиты, и т. д. При этом каждая клетка разбиения соответствует узлу дерева).
Иерархии (как и разбиение пространства) позволяют достаточно легко и просто производить частичное упорядочение граней. В результате получается список граней, практически полностью упорядоченный, что дает возможность применить специальные методы сортировки. Помимо упорядочения граней, иерархические структуры позволяют производить быстрое и эффективное отсечение граней, не удовлетворяющих каким-либо поставленным условиям. Рассмотрим основные методы удаления невидимых поверхностей. Метод трассировки лучей Наиболее естественным методом для определения видимости граней является метод трассировки лучей (вариант, используемый только для определения видимости, без отслеживания отраженных и преломленных лучей обычно называется ray casting). В данном методе для каждого пиксела картинной плоскости определяется ближайшая к нему грань, для чего через этот пиксел выпускается луч, находятся все точки его пересечения с гранями и среди них выбирается ближайшая. Данный алгоритм можно представить следующим образом: for all pixels for all objects compare z Одним из преимуществ этого метода является простота, универсальность (он может легко работать не только с полигональными моделями; возможно использование Constructive Solid Geometry - CSG) и возможность совмещения определения видимости с расчетом цвета пиксела. Еще одним несомненным плюсом метода является большое количество методов оптимизации, позволяющих работать с сотнями тысяч граней и обеспечивающих временные затраты порядка О (Clog л), где С - общее количество пикселов на экране, а п - общее количество объектов в сцене. Более того, существуют методы, обеспечивающие практическую независимость временных затрат от количества объектов. Метод z-буфера Одним из самых простых алгоритмов удаления невидимых граней и поверхностей является метод z-буфера (буфера глубины), где для каждого пиксела, как и в методе трассировки лучей, находится грань, ближайшая к нему вдоль направления проектирования, однако здесь циклы по пикселам и по объектам меняются местами.
for all objects for all covered pixels compare z Поставим в соответствие каждому пикселу (х, у) картинной плоскости, кроме цвета с(х, у), хранящегося в видеопамяти, его расстояние до картинной плоскости вдоль направления проектирования z(x, у) (его глубину). Массив глубин инициализируется +оо. Для вывода на картинную плоскость произвольной грани она переводится в растровое представление на картинной плоскости и затем для каждого пиксела этой грани находится его глубина. В случае, если эта глубина меньше значения глубины, хранящегося в z-буфере, пиксел рисуется и его глубина заносится в z-буфер. Весьма эффективным является совмещение растровой развертки грани с выводом в z-буфер. При этом для вычисления глубины пикселов могут применяться инкрементальные методы, требующие всего нескольких сложений на пиксел. Грань рисуется последовательно строка за строкой; для нахождения необходимых значений используется линейная интерполяция. Фактически метод z-буфера осуществляет поразрядную сортировку по х и у, а затем сортировку по г, требуя всего одного сравнения для каждого пиксела каждой грани. Метод z-буфера работает исключительно в пространстве картинной плоскости и не требует никакой предварительной обработки данных. Порядок, в котором грани выводятся на экран, не играет никакой роли. Для экономии памяти можно отрисовывать не все изображение сразу, а рисовать по частям. Для этого картинная плоскость разбивается на части (обычно это горизонтальные полосы) и каждая такая часть обрабатывается независимо. Размер памяти под буфер определяется размером наибольшей из этих частей. Современные графические ускорители несут в себе аппаратную реализацию z-буфера, включая также и аппаратную растеризацию граней вместе с закрашиванием. Средние временные затраты данного метода составляют О(п), где п -общее количество граней. Одним из основных недостатков z-буфера (помимо большого объема требуемой под буфер памяти) является избыточность вычислений: осуществляется вывод всех граней, вне зависимости от того, видны они или нет. Несли, например, данный пиксел накрывается 10 различными лицевыми гранями, то для каждого соответствующего пиксела каждой из этих 10 граней
необходимо произвести расчет цвета. При использовании сложных моделей освещенности (например, модели Фонга) и текстур эти вычисления могут потребовать слишком больших временных затрат. Рассмотрим в качестве примера модель здания с комнатами и всем, находящимся внутри них. Общее количество граней в подобной модели может составлять сотни тысяч и миллионы. Однако, находясь внутри одной из комнат этого здания, наблюдатель реально видит только весьма небольшую часть граней (несколько сотен). Поэтому вывод всех граней является непозволительной тратой времени. Существует несколько модификаций метода z-буфера, позволяющих заметно сократить количество выводимых граней. Одним из наиболее мощных и элегантных является метод иерархического z-буфера. При использовании перспективного проектирования значения глубины, соответствующие пикселам одной грани, изменяются уже нелинейно, в то же время величина 1/ z изменяется линейно и поэтому возникает понятие w-буфера, в котором вместо величины z хранится изменяющаяся линейно величина w = 1/z . Существует модификация метода z-буфера, позволяющая работать с прозрачными объектами и использовать CSG-объекты: для каждого пиксела (х, у) вместо пары (с, z) хранится упорядоченный по z список (С, z, г, ptr), где t - степень прозрачности объекта, a ptr - указатель на объект, и сначала строится буфер, затем для CSG-объектов осуществляется их раскрытие (см. метод трассировки лучей) и с учетом прозрачности рассчитываются цвета. Алгоритмы упорядочения Одним из самых простых способов построения изображения с удалены-ми невидимыми поверхностями является упорядочение граней и вывод их в порядке приближения к наблюдателю. Подобный алгоритм можно описать следующим образом: sort objects by z for all objects for all visible pixels paint Тем самым методы упорядочения выносят сравнение по глубине за пределы циклов и производят сортировку граней явным образом. Методы упорядочивания являются гибридными методами, осуществляющими сравнение и разбиение граней в объектном пространстве, а для
непосредственного наложения одной грани на другую использующими растровые свойства дисплея. Упорядочим все лицевые грани таким образом, чтобы при их выводе в этом порядке получалось корректное изображение сцены. Для этого необходимо, чтобы для любых двух граней Р и Q та из них, которая при выводе может закрывать другую, выводилась позже. Такое упорядочение обычно называется back-to-front, поскольку сначала выводятся более далекие грани, а затем более близкие. Существуют различные методы ___ построения подобного упорядоче- \ \л ния. Вместе с тем нередки и случаи, \ \ когда заданные грани упорядочить // \\ нельзя (рис. 2.13). Тогда необходимо / / \\ произвести дополнительное раз- / / \\ биение граней так, чтобы получив- г~ ' / шееся после разбиения множество \/ граней уже можно было бы упорядочить. Рис. 2.13 -----ул Заметим, что две любые выпук-\ лые грани, не имеющие общих внут- \ \ ренних точек, можно упорядочить \ \ всегда. Для невыпуклых граней это \ \ в общем случае неверно (рис. 2.14). Рис. 2.14 Метод сортировки по глубине. Алгоритм художника Этот метод является самым простым из методов, основанных на упорядочении граней. Как художник сначала рисует более далекие объекты, а затем поверх них более близкие, так и метод сортировки по глубине сначала упорядочивает грани по мере приближения к наблюдателю, а затем выводит их в этом порядке. Метод основывается на следующем простом наблюдении: если для двух граней А и В самая дальняя точка грани А ближе к наблюдателю (картинной плоскости), чем самая ближняя точка грани В, то грань В никак не может закрыть грань А от наблюдателя.
Поэтому если заранее известно, что для любых двух лицевых граней ближайшая точка одной из них находится дальше, чем самая дальняя точка другой, то для упорядочения граней достаточно просто отсортировать их по расстоянию от наблюдателя (картинной плоскости). Однако такое не всегда возможно: могут встретиться такие пары граней, что самая дальняя точка одной находится к наблюдателю не ближе, чем самая близкая точка другой. На практике часто встречается следующая реализация этого алгоритма: множество всех лицевых граней сортируется по ближайшему расстоянию до картинной плоскости (наблюдателя) и потом эти грани выводятся в порядке приближения к наблюдателю. В качестве алгоритмов сортировки можно использовать либо быструю сортировку, либо поразрядную (radix sort). Метод хорошо работает для целого ряда типичных сцен, включая, например, построение изображения нескольких непересекающихся простых тел. Хотя подобный подход и работает в подавляющем большинстве случаев, однако возможны ситуации, когда просто сортировка по расстоянию до картинной плоскости не обеспечивает правильного упорядочения граней (рис. 2.15), -так, грань В будет ошибочно выведена раньше, чем грань А; поэтому после сортировки желательно проверить порядок, в котором грани будут выводиться. Предлагается следующий алгоритм этой проверки. Для простоты будем считать, что рассматривается параллельное проектирование вдоль оси Oz. Перед выводом очередной грани Р следует убедиться, что никакая другая грань Q, которая стоит в списке после Р и проекция которой на ось Oz пересекается с проекцией грани Р (если пересечения нет, то порядок вывода Р и Q определен однозначно), не может закрываться гранью Р. В этом случае грань Р действительно должна быть выведена раньше, чем грань Q. Ниже приведены 4 теста в порядке возрастания сложности проверки. 1. Пересекаются ли проекции этих граней на ось 0x2 2. Пересекаются ли проекции этих граней на ось Оу ?
Если хотя бы на один из этих двух вопросов получен отрицательный ответ, то проекции граней Р и Q на картинную плоскость не пересекаются и, следовательно, порядок, в котором они выводятся, не имеет значения. Поэтому будем считать, что грани Р и Q упорядочены верно. Для проверки выполнения этих условий очень удобно использовать ограничивающие р тела. В случае, когда оба эти теста дали утвердительный ответ, проводятся следующие тесты. 3. Находится ли грань Р по другую сторону от плоскости, проходящей через грань Q, относительно наблюдателя (рис. 2.16)? 4. Находится ли грань Q по ту же стороже. 2.16 ну от плоскости, проходящей через грань Р, по которую находится и наблюдатель (рис. 2.17)? Если хотя бы на один из этих вопросов р получен утвердительный ответ, то считаем, ч. что грани Р и Q упорядочены верно, и срав- ниваем Р со следующей гранью. .Z4 >7 В случае, если ни один из тестов не под- твердил правильность упорядочения граней q Р и Q, проверяем, не следует ли поменять ' эти грани местами. Для этого проводятся Рис. 2.17 тесты, являющиеся аналогами тестов 3 и 4 (очевидно, что снова проводить тесты 1 и 2 не имеет смысла): 3'. Находятся ли грань Q и наблюдатель по разные стороны от плоскости, проходящей через грань Р? 4'. Находится ли грань Р по ту же сторону от плоскости, проходящей через грань Q, по которую находится и наблюдатель? В случае, если ни один из тестов 3, 4, 3', 4' не позволяет с уверенностью определить, какую из этих двух граней нужно выводить раньше, одна из них разбивается плоскостью, проходящей через другую грань, и вопрос об упорядочении целой грани и частей разбитой грани легко решается при помощи тестов 3 или 4 (3' или 4'). Возможны ситуации, когда, несмотря на то что грани Р и Q упорядочены верно, их разбиение все же будет произведено (алгоритм создает избы-
точные разбиения). Подобный случай изображен на рис. 2.18, где для каж- дой вершины указана ее глубина. Методу упорядочения присущ тот же недостаток, которых присущ и методу z-буфера, а именно необходимость вывода всех лицевых граней. Чтобы избежать этого, можно его модифицировать следующим образом: грани выводятся в обратном порядке - начиная с самых близких и заканчивая самыми далекими (front-to-back). При выводе очередной грани рисуются только те пикселы, которые еще не были выведены. Как только весь экран будет заполнен, вывод граней можно пре Рис. 2.18 кратить. Но здесь нужен механизм отслеживания того, какие пикселы были выведены, а какие нет. Для этого могут быть использованы самые разнообразные структуры, от линий горизонта до битовых масок. Замечание Рассмотрим подробнее, каким именно образом осуществляются проверки тестов 3 и 4. Пусть грань Р задана набором вершин Д, i = 1,..., п, а грань Q - набором вершин В, , j = 1,..., т. J J Тогда для определения плоскости, проходящей через грань Р, достаточно знать вектор нормали п к этой грани и какую-либо ее точку, например А]. Уравнение плоскости, проходящей через грань Р, имеет следующий вид: (щг)-(Д,п) = 0, (2.5) где вектор нормали задается формулой п =[А2 - Д, Д-Д]. (2.6)
Грань Q лежит по ту же сторону от плоскости, проходящей через грань Р, по которую располагается и наблюдатель, находящийся в точке V, если sign{(n, А, )} = sign[(n, 0)-(л, A,)}, i = 1,..., т , (2.7) и по другую сторону, если sign{(n, B^-fn, А, )} = -sign{(n, 9)-(n, A,)}, i - 1,..., т . (2.8) Метод двоичного разбиения пространства Существует другой, довольно элегантный и гибкий способ упорядочения граней. Каждая плоскость в объектном пространстве разбивает все пространство на два полупространства. Считая, что эта плоскость не пересекает ни одну из граней сцены, получаем разбиение множества всех т*раней на два не-пересекающихся множества (кластера); каждая грань попадает в тот или иной кластер в зависимости от того, в каком полупространстве относительно плоскости разбиения эта грань находится. Ясно, что ни одна из граней, лежащих в полупространстве, не содержащем наблюдателя, не может закрывать собой ни одну из граней, лежащих в том же полупространстве, в котором находится и наблюдатель (с небольшими изменениями это работает и для параллельного проектирования). Для построения правильного изображения сцены необходимо сначала выводить грани из дальнего кластера, а затем из ближнего. Применим предложенный подход для упорядочения граней внутри каждого кластера. Для этого выберем две плоскости, разбивающие каждый из кластеров на два подкластера. Повторяя описанный процесс до тех пор, пока в каждом получившемся кластере останется не более одной грани (рис. 2.19), получаем в результате двоичное дерево (Binary Space Partitioning Tree - BSP). Узлами этого дерева являются плоскости, производящие разбиение. Рис. 2.19
Пусть плоскость, производящая разбиение, задается уравнением (p,n) = Каждый узел дерева можно представить в виде следующей структуры: struct BSPNode { Plane plane; Facet * facet; BSPNode * front; BSPNode * back; // splitting plane // corresponding facet // left subtree // right subtree где front указывает на вершину поддерева, содержащуюся в положительном полупространстве (р, п) > d, a back - на поддерево, содержащееся в отрицательном полупространстве (р, n) < d. Обычно в качестве разбивающей плоскости выбирается плоскость, проходящая через одну из граней. Все грани, пересекаемые этой плоскостью, разбиваются вдоль нее, а получившиеся при разбиении части помещаются в соответствующие поддеревья. Рассмотрим сцену, представленную на рис. 2.20. Плоскость, проходящая через грань 5, разбивает грани 2 и 8 на части 2', 2", 8’ и 8", и все множество граней (с учетом разбиения граней 2 и 8) распадается на два кластера (1, 8', 2') и (2", 3, 4, 6, 7, 8"). Выбрав для первого кластера в качестве разбивающей плоскости плоскость, проходящую через грань 6, разбиваем его на два подкластера (7, 8") и (2", 3, 4). Каждое следующее разбиение будет лишь выделять по одной грани из оставшихся кластеров. В результате получим следующее дерево (рис. 2.21). Рис. 2.20 Рис. 2.21
Таким образом, процесс построения BSP-деревьев заключается в выборе разбивающей плоскости (грани), разбиении множества всех граней на две части (это может потребовать разбиения граней на части) и рекурсивного применения описанной процедуры к каждой из получившихся частей. Замечание. Если проверяемая грань лежит в плоскости разбиения, то ее можно поместить в любую из частей. Существуют варианты метода, которые с каждым узлом дерева связывают список граней, лежащих в разбивающей плоскости. Алгоритм построения BSP-дерева очень похож на известный метод быстрой сортировки Хоара и его реализация может быть найдена на компакт-диске (или на сайте автора). Получающиеся деревья сильно зависят от выбора разбивающих граней. На рис. 2.22 приведено дерево для сцены с рис. 2.20 с другим порядком выбора разбивающих граней. Обратите внимание на отсутствие разбиения граней в этом случае. При разбиении грани плоскостью производится классификация всех вершин грани относительно этой плоскости и разбиение тех ребер, вершины которых лежат в разных полупространствах; сами точки разбиения считаются принадлежащими каждому из получившихся многоугольников. Естественным образом возникает вопрос о построении дерева, в некотором смысле оптимального. Существует два основных критерия оптимальности: • получение как можно более сбалансированного дерева (когда для любого узла количество граней в правом поддереве минимально отличается от количества граней в левом поддереве); это обеспечивает минимальную высоту дерева (и соответственно наименьшее количество проверок); • минимизация количества разбиений; одним из наиболее неприятных свойств BSP-деревьев является разбиение граней, приводящее к значительному увеличению их общего числа и, как следствие, к росту затрат (памяти и времени) на изображение сцены.
К сожалению, эти критерии, как правило, являются взаимоисключающими. Поэтому обычно выбирается некоторый компромиссный вариант; например, в качестве критерия выбирается сумма высоты дерева и количества разбиений с заданными весами. Полный анализ реальной сиены с целью построения оптимального дерева из-за очень большого количества вариантов произвести практически невозможно. Поэтому обычно поступают следующим образом: на каждом шаге разбиения случайным образом выбирается небольшое количество кандидатов на разбиение и выбор наилучшего в соответствии с выбранным критерием производится только среди этих кандидатов. В случае, когда целью является минимизация числа разбиений, можно воспользоваться следующим приемом: на очередном шаге выбирать ту грань, использование которой приводит к минимальному числу разбиений на данном шаге. После того как это дерево построено, построение изображения осуществляется в зависимости от используемого проектирования. Ниже приводится процедура построения изображения для центрального проектирования с центром в точке с. S1 void drawBSPTree ( BSPNode * tree ) { if ( tree -> plane.classify ( c ) == IN_FRONT ) { if ( tree -> right != NULL ) drawBSPTree ( tree -> right ); drawFacet ( tree -> facet ); if ( tree -> left != NULL ) drawBSPTree ( tree -> left ) ; } else { if ( tree -> left != NULL ) drawBSPTree ( tree -> left ) ; drawFacet ( tree -> facet ); if ( tree -> right != NULL ) drawBSPTree ( tree -> right ); } }
Приведенная процедура осуществляет вывод граней в порядке back-io-front. Эту процедуру можно модифицировать для вывода только лицевых граней. Несложно также скорректировать ее и для работы с параллельным проектированием. Если необходимо выводить грани в обратном порядке (front-to-back), то отработку левого и правого поддеревьев следует поменять местами. Но тогда потребуется механизм отслеживания уже заполненных пикселов экрана. Как только все пикселы будут заполнены, рекурсивный обход дерева можно прекратить. Одним из основных преимуществ этого метода является полная независимость дерева от параметров проектирования (положения центра проектирования, направления проектирования и др.), что делает его весьма удобным для построения серий изображений одной и той же сцены из разных точек наблюдения. Это обстоятельство привело к тому, что BSP-деревья стали широко использоваться в ряде систем виртуальной реальности. В частности, удаление невидимых граней в широко известных играх DOOM и всех играх серии Quake основано на привлечении именно ВSP-деревьев. В ряде случаев удобнее строить дерево, листьями которого будут не грани, а выпуклые многогранники, порядок, в котором выводятся лицевые грани выпуклых многогранников, может быть произволен и никакого влияния на конечный результат не оказывает. Причем BSP-дерево используется только для упорядочения этих многогранников, а уже упорядочение граней внутри каждого из них осуществляется другим способом. К недостаткам метода BSP-деревьев относятся явно избыточная необходимость разбиения граней, особенно актуальная при работе с большими сценами, и нелокальность BSP-деревьев - даже незначительное локальное изменение сцены может повлечь за собой изменение практически всего дерева. Вследствие их нелокальное™ представление больших сцен в виде BSP-деревьев оказывается слишком сложным, так как приводит к очень большому количеству разбиений. Для борьбы с этим явлением можно разделить всю сцену на несколько частей, которые можно легко упорядочить между собой, и для каждой из этих частей построить свое BSP-дерево, содержащее только ту часть сцены, которая попадает в данный фрагмент. Построенное по набору граней BSP-дерево разбивает все пространство на множество выпуклых областей, границами которых служат грани и плоскости разбиения. Так, сцена с рис. 2.21 разбивается на 3 области - (1, 2', 5, 8'), (2", 3, 4) и (6, 7, 8"). Обратите внимание, что внутри каждой из получившихся частей нет необходимости в специальном упорядочении граней - в силу
выпуклости ни одна из граней в пределах одной из областей не может закрывать ни одну из других граней в той же области. Тем самым для правильного упорядочения граней внутри всей сцены достаточно лишь иметь разбиение всей сцены на набор выпуклых областей и затем упорядочить их при помощи BSP-дерева. Все лицевые грани в пределах каждой из таких областей выводятся в любом порядке. Таким образом мы приходим к понятию листового {leaf) BSP-дерева, когда дерево строится для разбиения пространства на набор выпуклых областей и их последующего упорядочения. Процедура построения такого дерева отличается от процедуры построения обычного BSP-дерева тем, что разбиение кластера продолжается до тех пор, пока оставшиеся в кластере грани не являются частью границы выпуклой области. Для определения этого достаточно проверить, лежат ли все грани по одну сторону от плоскости, проведенной через каждую из этих граней. Еще одной особенностью листового BSP-дерева заключена в том, что в листовом дереве листья могут быть представлены другим классом объектов, чем внутренние узлы. В играх DOOM и Quake были использованы именно листовые BSP-деревья. Метод порталов Существует достаточно простой подход, позволяющий прямо на ходу определять видимость граней, не прибегая к помощи BSP-деревьев. Разобьем сцену на набор выпуклых Областей и рассмотрим, как эти области соединены между собой. Те соединения, через которые можно видеть (окна, дверные проемы), называются порталами. Ясно, что все грани, принадлежащие той ячейке, в которой находится наблюдатель, заведомо могут быть видны. Рассмотрим порталы, соединяю
щие данную ячейку с соседними. Если какие-то грани и могут быть видны, то только через эти порталы. Поэтому выделим области, соединенные с текущей областью порталами, и в них те грани, которые видны через соединяющие их порталы. Далее для областей, соседних с начальной, рассмотрим соседние области. Они также могут быть видны только через соединяющие порталы. Поэтому выделим те грани, которые могут быть видны (теперь уже через два последовательных портала), и т. д. Подобным путем можно легко построить некоторое множество граней, потенциально видимых из данной точки. Возможно, этот список окажется несколько избыточным, но тем не менее он будет заметно меньше, чем общее число граней. Рассмотрим сцену, представленную на рис. 2.25. Порталы обозначены пунктирными линиями. Пусть наблюдатель находится в комнате 0-1-2-27-28. Очевидно, что он видит все лицевые грани в этой комнате. Кроме того, через портал 3-26 он видит комнату 3-4-25-26, а через портал 4-25, - комнату 4-5-6-7-16-17-24-25 и т. д. Сначала достаточно нарисовать лицевые грани из текущей комнаты, затем для каждого портала, принадлежащего этой комнате, нужно нарисовать видимые сквозь портал части лицевых граней смежных комнат, используя при этом соответствующий портал как область отсечения. Рассмотрим комнаты, соединенные порталами с комнатами, соседними с начальной, и нарисуем те их лицевые грани, которые видны через суперпозицию (пересечение) сразу двух порталов, ведущих в соответствующую комнату, и т. д. Если пересечение порталов пустое множество, то данная комната из начальной точки, где находится наблюдатель, не видна.
Получившийся алгоритм реализует следующий фрагмент псевдокода: Е1 def renderscene ( viewFrrustrum, activeCell ): for wall in activeCell.walls: if wall.intersect ( viewingFrustrum ): clippedWall = clippolygon ( wall, viewFrustrum ) drawPolygon ( clippedWall ) for portal in activeCell.portals: if portal.intersect ( viewingFrustrum ): clippedPortal = clipPolygon ( portal, viewFrustrum ) renderscene ( viewFrustrum, portal.adjacentCell ( activeCell ) ) Так как в большинстве случаев все многоугольники являются выпуклыми, то в результате требуется процедура отсечения одного выпуклого многоугольника по другому выпуклому многоугольнику. В классическом методе порталов вся сцена разбивается на набор выпуклых комнат - при этом все лицевые грани, видимые через портал, не могут закрывать собой другие лицевые грани в пределах той же комнаты. Тем самым в этом случае задача определения видимости полностью решена, более того, затраты на рендеринг сцены прямо пропорциональны количеству реально видимых граней, в отличие от других методов. Разбиение сцены на набор выпуклых комнат может производиться при помощи листового BSP-дерева. Используя это же BSP-дерево, можно также построить и порталы для каждой из получающихся комнат. Рассмотрим этот процесс подробнее. Для сцены с рис. 2.26 листовое BSP-дерево будет выглядеть следующим образом (рис. 2.27).
Рассмотрим теперь плоскость р и разобьем эту плоскость при помощи плоскостей, проходящих через грани комнат А и В. В результате мы получим портал, соединяющий эти комнаты. Обратите внимание, что плоскость должна разбиваться как при помощи всех плоскостей BSP-дерева, так и при помощи всех плоскостей, проходящих через грани данной комнаты. Тем самым процесс построения выпуклых комнат и порталов по заданному набору граней полностью автоматизируется, но при этом количество порталов и комнат может получиться очень большим (в ряде случаев их количество оказывается даже сравнимым с. общим числом граней). Поскольку обработка портала является достаточно дорогостоящей операцией (по сравнению с обработкой обычной грани), то подобный подход не всегда применим в системах, работающих в режиме реального времени (или накладывает довольно жесткие ограничения на геометрию сцены). Еще одним недостатком метода порталов является его ограниченная применимость. Этот метод хорошо подходит лишь для сцен, являющихся внутренностями архитектурных сооружений, а для ряда других типов сцен (например, ландшафтных) практически неприменим. Множества потенциально видимых граней (PVS) Одним из подходов, активно использующихся для определения видимости в сложных сценах, является метод, который после разбиения сцены на набор фрагментов (обычно выпуклых) заранее (на этапе подготовки сцены) для каждого такого фрагмента строит список граней, видимых из него. Такие списки называются множествами потенциально видимых граней (Potentially Visible Set - PVS). Первой игрой, успешно применившей подобный подход, была игра Quake. Как и в методе порталов, сперва сцена разбивается на множество выпуклых комнат. После этого для каждой такой комнаты строится список всех граней, которые может увидеть наблюдатель, находящийся внутри данной комнаты. При этом, учитываются всевозможные положения и ориентации наблюдателя внутри комнаты и строится максимальное множество видимости - все, что может быть видно хотя бы для каких-то одних положения и ориентации наблюдателя внутри комнаты. После того как для всех комнат такие множества построены, они сохраняются и в дальнейшем используются при рендеринге всей сцены. Метод рендеринга в данном случае крайне прост - определяется, в какой комнате находится наблюдатель, и определяется соответствующее
множество потенциально видимых граней. Используя информацию о точных местоположении и ориентации наблюдаетеля, можно отбросить из этого множества заведомо невидимые грани (нелицевые и не попадающие в область видимости наблюдателя). После этого для точного определения видимости среди оставшихся граней используется один из традиционных методов определения видимости - например, метод г-буфера. При этом довольно часто для разбиения всей сцены на набор выпуклых комнат используется BSP-дерево. Именно этот подход и применялся во всех играх Quake. Реализации рендеринга для уровней из игры Quake 2 рассматриваются в гл. 11. Рассмотрим теперь, каким образом можно построить соответствующие множества потенциально видимых граней для всей сцены. Сначала вся сцена разбивается при помощи BSP-дерева на набор выпуклых комнат, как описано ранее, и строятся порталы, соединяющие полученные комнаты между собой. Но в отличие от метода порталов здесь порталы используются только для построения множеств потенциально видимых граней (PVS) и в самом процессе рендеринга вообще не используются. После построения порталов для каждой из комнат осуществляется построение соответствующего множества потенциально видимых граней. Один из способов для построения PVS заключается в использовании так называемой антитени (anti-penumbrae). Проще всего представить антитень как освещенную область, если считать, что вся комната залита светом (каждая точка внутри является источником света). При этом свет через порталы будет попадать в другие комнаты. Причем любая комната, в которую попадает свет, может быть видна из исходной для каких-то положения и ориентации наблюдателя. Очевидно, что каждая комната, непосредственно соединенная с данной при помощи портала, будет видна - так, комната dstLeaf всегда будет видна из комнаты srcLeaf через соединяющий их портал (рис. 2.28). Рис. 2.28
Поэтому задача заключается в определении того, какие из комнат, соединенных с dstLeaf при помощи порталов (кроме srcLeaf), также будут видны. Для определения того, может ли комната genLeaf быть хотя бы частично видимой из srcLeaf через пару порталов - srcPortal и dstPortal, построим по этой паре порталов антитень (рис. 2.29). Антитень - это область пространства по другую сторону от dstPortal, ограниченная следующим набором плоскостей: через каждую вершину одного из порталов и каждое ребро другого портала проводится плоскость. При этом в построении антитени участвуют тольке те плоскости, для которых образующие их порталы лежат по разные стороны (рис. 2.30). Тогда часть комнаты genLeaf, лежащая в построенной антитени, будет видна из исходной комнаты srcLeaf через пару порталов srcPortal и dstPortal. Далее рассматриваем другие комнаты, непосредственно соединенные с genLeaf при помощи порталов. Порталы, хотя бы частично не попадающие в антитень, сразу же отбрасываются, как заведомо невидимые (рис. 2.31).
Так, портал В в антитень не попадает и поэтому сразу же может быть отброшен. С другой стороны, портал А частично попадает в антитень и поэтому через него может быть видна лежащая за ним комната. Следующим шагом будет определение видимости за порталом А. Для этого портал А сначала обрезается по границе антитени, и комната, лежащая за ним, становится новым generatorLeaf. После этого описанная процедура применяется снова. Обратите внимание, что на рис. 2.32 вместо портала Я используется обрезанный на предыдущем шаге портал А
В результате мы получаем рекурсивную процедуру определения видимости. Она может быть выражена при помощи следующего фрагмента псевдокода: К1 def buildPvs (leaf, pvs): pvs = [leaf] for srcPortal in leaf.portals: dstLeaf = srcPortal.leafs [0] if dstLeaf == leaf: dstLeaf = srcPortal.leafs [1] for dstPortal in dstLeaf.portals: if dstPortal.plane != srcPortal.plane: recursePvs ( leaf, srcPortal, dstLeaf, dstPortal, pvs ) Функция recursePvs служит для рекурсивного обхода цепочки комнат и порталов. В ходе ее работы по очереди выбирается genLeaf п проверяются все его порталы. ЦЖ. def recursePvs ( srcLeaf, srcPortal, dstLeaf, dstPortal, pvs ): genLeaf = dstPortal.leafs [0] if genLeaf == dstLeaf: genPortal = dstPortal.leafs [1] pvs.insert ( genLeaf ) for genPortal in genLeaf.portals: if dstPortal.plane == genPortal.plane: continue clippedPortal = clipPortalToAntiPenumbrae ( srcPortal, dstPortal, genPortal ) if clippedPorta.isEmpty: continue recursePvs ( srcLeaf, srcPortal, clippedPortal, genLeaf, pvs )
Процедура clipPortalToAntiPenumbrae служит для отсечения портала но антитени, построенной по двум другим порталам, .а def clipPortalToAntiPenumbrae ( srcPortal, dstPortal, portal ): planes = [] addClipPlanes ( srcPortla, dstPortal, planes ) addClipPlanes ( dstPortal, srcPortal, planes ) for plane in planes: loc = plane.classify ( portal ) sloe = plane.classify ( srcPortal ) if loc == sloe || loc == onPlane: # portal lies on the same side or in plane return [] if loc == inBack && sloe == inFront: continue if loc == inFront && sloe == inBack: continue if loc == spanning: # plane splits portal in two parts portal.split ( plane, front, back ) if sloe == inFront: portal = back elif sloe == inBack: portal = front return portal Процедуры addClipPlanes, которые мы приводить не будем, служат для построения набора плоскостей по паре порталов. При этом плоскости проводятся через вершины первого портала и ребра второго портала. В список добавляются только те плоскости, для которых оба портала лежат по разные стороны.
Глава 3. ПРОСТЕЙШИЕ ГЕОМЕТРИЧЕСКИЕ АЛГОРИТМЫ И СТРУКТУРЫ Быстрая оценка длины вектора Иногда возникает задача определения длины вектора, причем довольно часто скорость выполнения операции гораздо важнее точности. Обычно для вычисления длины вектора в трехмерном пространстве необходимы три операции умножения, две операции сложения и одна операция извлечения квадратного корня, что делает вычисление длины довольно дорогостоящим. Ниже приводится довольно простой метод, позволяющий быстро оценить длину вектора ценой довольно небольших затрат. С каждым вектором v = (x, у, z /можно связать координатную ось, проекция вектора на которую имеет наибольшую длину (рис. 3.1). В качестве такой оси выбирается ось, соответствующая наибольшему по модулю значению компоненты вектора: iv =argmax|vj, где в качестве v, выступают координаты х, у и z 64 znwa-ликзи
Тогда в качестве оценки длины вектора может выступать |v, |. Для вычисления этой величины можно использовать следующий фрагмент кода: It float distariceToAlongAxis ( const Vector3D& p, int axis ) const { return (float)fabs ( operator [] ( axis ) - p [axis] ) ; } Для вычисления главной оси вектора мы будем использовать следующий метод: Т2| Л. intgetMainAxis () const { int axis = 0; float val = (float) fabs ( x ); for ( register int i = 1; i < 3; i++ ) { float vNew = (float) fabs ( operator [] ( i ) ); if ( vNew > val ) { val = vNew; axis = i; } } return axis; } Особенно эффективно применение данного подхода, когда нужно быстро оценить расстояние до группы объектов вдоль одного и того же вектора, - тогда по этому вектору один раз вычисляется главная ось и расстояние определяется вдоль нее. Нахождение расстояния от точки до прямой Пусть заданы точка р(х, у, г) и прямая org + t-l и требуется найти расстояние от данной точки до этой прямой. Решение этой задачи в двухмерном случае приводится в [1]. Ниже приводится общее решение, пригодное для трехмерного пространства.
Для начала спроектируем точку р на прямую, представив ее как сумму вектора вдоль прямой и вектора, ортогонального направляющему вектору прямой: р = org +t' -l + lL, где вектор I1 перпендикулярен вектору / (рис. 3.2). rf=||p-/|| = ||org+/-Z-p||. Для определения параметра t' умножим скалярно это уравнение на вектор /. Тогда мы получим (р,/) = (ог#,/) + г'(/,/). Отсюда легко определяется параметр t' проекции точки р на прямую: t' = Тогда искомым расстоянием будет расстояние между точками р и р'. Ограничивающие тела Одной из наиболее частых операций, которые будут встречаться в графической (да и не только в ней) части игры, является проверка объектов на пересечение между собой, попадание в заданную область или выполнение каких-либо других условий, связанных в положением объектов. Поскольку эта задача встречается очень часто, то ее правильная реализация может заметно повысить быстродействие всей системы в целом. В этой главе мы рассмотрим основные алгоритмы и структуры данных для эффективного осуществления таких проверок, а также примеры их реализации. Одной из самых простых, но очень часто встречающихся структур, являются ограничивающие тела (bounding volumes). Их использование основывается на следующем крайне простом наблюдении: если вокруг сложного объекта описать некоторое тело, полностью содержащее его в себе, то если это тело не пересекает другой объект (или не попадает в заданную область),
то и любой содержащийся внутри него объект также не пересекает данный объект (или не попадает в область) (рис. 3.3). В случае, когда описанное тело достаточно простое, осуществление проверки с ним может оказаться гораздо проще (и быстрее), чем с исходным объектом. Это становится особенно выгодным, когда нужно проверить на пересечение два сложных объекта (часто состоящих из множества мелких элементов), - тогда проверка описанных вокруг них тел может дать огромный выигрыш в скорости. В качестве таких описанных тел (их обычно называют ограничивающими телами) обычно рассматривают простейшие выпуклые тела - сферы или эллипсоиды, прямоугольные параллелепипеды и т. п. Следует иметь в виду, что если ограничивающие тела для двух объектов пересекаются между собой, то из этого еще не следует факт пересечение исходных объектов (рис. 3.4), поэтому в этом случае может потребоваться проведение дополнительной проверки, более точно учитывающей геометрию исходных тел. Однако это оказывается необходимым только в том случае, когда тест пересечения ограничивающих тел дал положительный результат, что бывает не так часто.
Самым простым ограничивающим телом является сфера, описанная вокруг заданного объекта (или набора его вершин) (рис. 3.5). Рис. 3.5 Простейшим способом построения такой сферы является нахождение средней точки заданного набора вершин и вычисление максимального расстояния от него до всех точек набора. Пусть задан набор точек v0, v,,..., vnl. Тогда в качестве центра сферы можно взять точку а в качестве ее радиуса г - ^/maxfc-v,, с-v,). (3.2) Проверка на попадание точки р внутрь ограничивающей сферы крайне проста: точка лежит внутри, если выполнено следующее неравенство: (р — с, р-с)<гг. (3.3) Также легко проверить две ограничивающие сферы на пересечение: если первая сфера задана набором (с,, г,), а вторая - набором (с2, г2), то эти сферы имеют непустое пересечение тогда и только тогда, когда (с2-срс2-с1)<(/1+ г,)2. (3.4)
Еще одной часто встречающейся операцией над ограничивающим телом является добавление новой точки (или ограничивающего тела) к заданному гелу. При добавлении точки р к заданной ограничивающей сфере (с, г) центр сферы обычно не изменяется, а в качестве нового радиуса берется max (г, ||с- р||). При объединении двух ограничивающих сфер (q, г,) и (с2, г2) параметры (с, г) для новой сферы, содержащей их обе внутри себя, задаются следующими формулами (рис. 3.6): Также часто встречается необходимость проверить на попадание ограничивающей сферы в заданное полупространство (или пересечение полупространств). Пусть полупространство задается неравенством (п, p) + d> О, а сфера - набором (с, г).
Тогда, если расстояние от центра сферы до плоскости л: (и, p) + d=O больше радиуса сферы, то она целиком лежит в том полупространстве, в котором лежит и ее центр (рис. 3.7). В противном случае сфера лежит сразу в обоих полупространствах. Расстояние от центра сферы с до плоскости п :(п, p) + d = 0 может быть найдено по следующей формуле (при условии, что вектор нормали п является единичным): dist(c, л) = (с, n) + d. (3.6) Описанные методы можно собрать вместе и реализовать в виде следующего класса: class BoundingSphere { private: Vector3D center; float radius; float radiusSq; public: BoundingSphere ( const Vector3D& c, float r ) : center ( c ), radius ( r ) { radiusSq = r * r; } BoundingSphere ( const Vector3D * v, int n ); const Vector3D& getCenter () const { return center; float getRadius () const { return radius;
void addPoint ( const Vector3D& p ) { float rSq = (p-center).lengthSq (); if ( rSq > radiusSq ) { radiusSq = rSq; radius = (float) sqrt ( radiusSq ) ; } } void merge ( const BoundingSphere& s ); bool contains ( const Vector3D& p ) const { return (p-center).lengthSq () <= radiusSq; } bool contains ( const BoundingSphere& s ) const { return (s.center - center).length () + s,radius <= radius; } bool intersects ( const BoundingSphere& s ) const { return (center - s.center).lengthSq () <= (radius + s.radius) * (radius + s.radius); } int classify ( const Plane& plane ) const { float v = plane.f ( center ); if ( v > radius || v < -radius ) return plane.classify ( center ); return IN_BOTH; } }; Еще одним часто встречающимся примером ограничивающего тела является прямоугольный параллелепипед с ребрами, параллельными осям координат {axis-aligned bounding box - ААВВ). Простейшим способом задания такого тела является задание двух точек pinill и р11ШХ, представляющих
собой покоординатные значения минимума и максимума из координат, содержащихся внутри точек. Тогда, если задан набор точек v0, v,, то Р,™.,, = min Ч.л-P,nin,y =minviv, Ртах..: = Шах Vf... Проверка на попадание точки р внутрь такого ограничивающего тела сводится к проверке выполнения следующих неравенств: Pmm.x Рх Рпыхл’ Pmin.y “ P.r “ Ртах.> ’ (3-8) Pmi„,: Р.- Ртах.:’ Аналогично при помощи покомпонентных неравенств выполняется проверка на пересечение двух ААВВ или попадание одного ААВВ внутрь другого. Рассмотрим теперь, каким образом осуществляется классификация ААВВ относительно плоскости л: (я, p) + d =0 (рис. 3.8). ------------------ Если вектор п единичный, то для произвольной точки р величина /(р) = (и, p) + d по модулю равна расстоянию отэтой точки до \ плоскости. Знак этой величины будет положительным, если точка s' лежит в положительном полупро- странстве ((р, «) + </> 0) и отри-п s' цательна в противном случае. Эту s' величину обычно называют рас- Рис. 3.8 стоянием до плоскости со знаком. Самым простым способом будет вычисление значения /(р) = (п, p) + d для всех восьми вершин ААВВ. Если все эти значения имеют одинаковый знак, то параллелепипед целиком лежит в соответствующем полупространстве. Однако этот процесс можно оптимизировать.
Обычно часто возникает необходимость сравнения целого набора ограничивающих тел с заданной плоскостью я. В этом случае вместо вычисления значения f во всех вершинах параллелепипеда достаточно вычислить >то значение всего в одной вершине, индекс которой определяется ориентацией вектора нормали к плоскости. На рис. 3.9, а-г показаны различные варианты расположения плоскости по отношению к ограничивающему телу и выделена соответствующая вершина для каждого из случаев. Рис. 3.9 Как легко увидеть из рисунка, для каждой из трех координат мы выбираем соответствующее значение из р^, если соответствующая координата вектора нормали к плоскости отрицательна, и из pinill - если положительна. Фактически выбирается ближайшая вершина параллелепипеда по направлению вектора нормали к плоскости. Удобно вычислить соответствующий индекс сразу же при создании плоскости. Таким образом, мы приходим к следующему классу, инкапсулирующему плоскость в трехмерном пространстве и основные операции над ней: class Plane { public: Vector3D n; float dist; int nearPointMask; int mainAxis; // normal vector // signed distance along n // build plane from normal and // signed distance // -1 if not initialized // index of main axis Plane ( const Vector3D& normal, float d ) : n (normal), nearPointMask ( -1 )
{ п.normalize (); dist = d; computeNearPointMaskAndMainAxis () ; } // build plane from plane equation Plane ( float nx, float ny, float nz, float d ) : n (nx, ny, nz), nearPointMask ( -1 ) { n.normalize () ; dist = d; computeNearPointMaskAndMainAxis () ; } // build plane from normal and point on plane Plane ( const Vector3D& normal, const Vector3D& point ) : n ( normal ) , nearPointMask ( -1 ) ( n.normalize (); dist = -(point & n) ; computeNearPointMaskAndMainAxis (); } // build plane from 3 points Plane ( const Vector3D& pl, const Vector3D& p2, const Vector3D& p3 ) : nearPointMask ( -1 ) ( n = (p2 - pl) л (p3 - pl ); n.normalize (); dist = -(pl & n); computeNearPointMaskAndMainAxis (); } Plane ( const Plane& plane ) : n ( plane.n ), dist ( plane.dist ) ( nearPointMask = plane.nearPointMask; mainAxis = plane.mainAxis; } float signedDistanceTo ( const Vector3D& v ) const ( return (v & n) + dist; } float distanceTo ( const Vector3D& v ) const ( return (float)fabs ( signedDistanceTo ( v ) ); }
// get point on plane Vector3D point () const { return (-dist) * n; } // classify point int classify ( const Vector3D& p ) const { float v = f ( p ) ; if ( v > EPS ) return IN_FRONT; else if ( v < -EPS ) return IN_BACK; return IN_PLANE; } // mirror position (point), depends on plane posit, void reflectPos ( Vector3D& v ) const { v -= (2.0f*((v & n) + dist)) * n; } // mirror direction, depends only on plane normal void reflectDir ( Vector3D& v ) const { v -= (2.0f*(v & n)) * n; } void reflectPlane ( Plane& plane ) const { Vector3D p (-plane.dist * plane.n); // point on plane reflectDir ( plane.n ); reflectPos ( p ); plane.dist = -(p & plane.n); plane.computeNearPointMaskAndMainAxis (); } void rotate ( const Matrix3D& rot ) { Vector3D p ( - dist*n );
П = rot * П; dist = - (р & п) ; computeNearPointMaskAndMainAxis (); } void flip () ( П = -П; dist = -dist; computeNearPointMaskAndMainAxis (); } float closestPoint ( const Vector3D& p, Vector3D& res ) const ( float distanceToPlane = - dist - (p & n); res = p + distanceToPlane * n; return distanceToPlane; } bool intersectByRay (const Vector3D& org, const Vector3D& dir,float& t) const ( float numer = - (dist + (org & n)); float denom = dir & n; if ( fabs ( denom ) < EPS ) return false; t = numer / denom; return true; } bool intersectByRay ( const Ray& ray, floats t ) const ( float numer = - (dist + (ray.getOrigin () & n)); float denom = ray.getDir () & n; if ( fabs ( denom ) < EPS ) return false;
t = numer / denom; return true; } Vector3D makeNearPoint(const Vector3D& minPoint, const Vector3D& maxPoint) const ( return Vector3D ( nearPointMask & 1 ? maxPoint.x : minPoint.x, nearPointMask & 2 ? maxPoint.у : minPoint.у, nearPointMask & 4 ? maxPoint.z : minPoint.z ); } Vector3D makeFarPoint(const Vector3D& minPoint, const Vector3D& maxPoint) const ( return Vector3D ( nearPointMask & 1 ? minPoint.x : maxPoint.x, nearPointMask & 2 ? minPoint.у : maxPoint.у, nearPointMask & 4 ? minPoint.z : maxPoint.z ); } int getMainAxis () const ( return mainAxis; } void apply ( const Transform3D& ); protected: void computeNearPointMaskAndMainAxis (); }; Ниже приводится описание основных методов этого класса. Метод flip переворачивает вектор нормали, при этом плоскость остается на месте, а положительное и отрицательное полупространства меняются местами. Метод closestPoint возвращает точку на плоскости, ближайшую к данной. Метод intersectByRay служит для определения точки пересечения плоскости с заданным лучом, возвращает признак наличия пересечения и значения параметра луча для точки пересечения. Метод apply позволяет применять произвольное аффинное преобразование к плоскости.
Методы signedDistanceTo и distanceTo возвращают расстояние со знаком и без него от точки до плоскости. Метод classify служит для определения положения точки относительно плоскости и возвращает, в каком полупространстве (или же в самой плоскости) лежит заданная точка. Методы reflectPos и reflectDir служат для отражения точки и направления относительно данной плоскости. Обратите внимание, что отражение вектора относительно плоскости происходит по-разному в зависимости от того, является ли данный вектор координатами точки (3.10, а) или направлением в пространстве (3.10, б). Конструктор класса Plane позволяет строить экземпляр класса по вектору нормали и расстоянию, по коэффициентам уравнения, нормали и точке на плоскости и трем точкам. Для задания ограничивающего тела в виде прямоугольного параллелепипеда с ребрами, параллельными осям координат (ААВВ), мы будем использовать следующий класс: га Л. class BoundingBox ( Vector3D minPoint; Vector3D maxPoint; public: BoundingBox ( const Vector3D& vl, const Vector3D& v2 ) ( if ( vl.x < v2.x ) ( minPoint.x = vl.x; maxPoint.x = v2.x; } else
{ minPoint.x = v2.x; maxPoint.х = vl.x; } if ( vl.у < v2.у ) { minPoint.y = vl.y; maxPoint.у = v2.y; } else { minPoint.y = v2.y; maxPoint.у = vl.y; } if ( vl.z < v2.z ) { minPoint.z = vl.z; maxPoint.z = v2.z; } else { minPoint.z = v2.z; maxPoint.z = vl.z; } BoundingBox () { reset (); void addVertex ( const Vector3D& v ) { if ( v.x < minPoint.x ) minPoint.x = v.x; if ( v.x > maxPoint.x ) maxPoint.x = v.x; if ( v.y < minPoint.y ) minPoint.y = v.y; if ( v.y > maxPoint.у ) maxPoint.у = v.y;
if ( v.z < minPoint.z ) minPoint. z = v.z; if ( v.z > maxPoint.z ) maxPoint. z = v.z; } void addVertices ( const Vector3D * v, int numVertices ) { for ( register int i = 0; i < numVertices; i++ ) { if ( v [i].x < minPoint.x ) minPoint.x = v [i].x; if ( v [i].x > maxPoint.x ) maxPoint.x = v [i].x; if ( v [i].y < minPoint.y ) minPoint.у = v [i].’y; if ( v [i].y > maxPoint. у ) maxPoint.у = v [i].y; if ( v [i].z < minPoint.z ) minPoint.z = v [i].z; if ( v [i].z > maxPoint . z ) maxPoint.z = v [i].z; } } int classify ( const Plane& plane ) const { Vector3D nearPoint = plane .makeNearPoint ( minPoint, maxPoint ); if ( plane.classify ( nearPoint ) == IN_FRONT ) return IN_FRONT; Vector3D farPoint = plane .makeFarPoint ( minPoint, maxPoint ) ; if ( plane. classify ( farPoint ) == IN_BACK ) return IN_BACK; return IN_BOTH; }
bool contains ( const Vector3D& pos ) const { return pos.x >= minPoint.x && pos.x <= maxPoint.x && pos.у >= minPoint.y && pos.у <= maxPoint.у && pos.z >= minPoint.z && pos.z <= maxPoint.z; } bool isEmpty () const { return minPoint.x > maxPoint.x || minPoint.y > maxPoint.у || minPoint.z > maxPoint.z; } bool intersects ( const BoundingBox& box ) const { if (( maxPoint.x < box.minPoint.x) || (minPoint.x > box.maxPoint.x) ) return false; if (( maxPoint.у < box.minPoint.y) || (minPoint.y > box.maxPoint.у) ) return false; if (( maxPoint.z < box.minPoint.z) || (minPoint.z > box.maxPoint.z) ) return false; return true; ) void reset () { minPoint.x = MAX_COORD; minPoint.y = MAX_COORD; minPoint.z = MAX_COORD; maxPoint.x = -MAX_COORD; maxPoint.у = -MAX_COORD; maxPoint.z = -MAX_COORD; ) Vector3D getMin () const { return minPoint; )
Vector3D getMax () const { return maxPoint; Vector3D getVertex ( int index ) const { return Vector3D ( index & 1 ? maxPoint.x : index & 2 ? maxPoint.у index & 4 ? maxPoint.z : minPoint.x, minPoint.y, minPoint.z ); Vector3D getCenter () const { return (minPoint + maxPoint) * 0.5f; float getSize () const { return-maxPoint.x - minPoint.x + maxPoint.у -minPoint.y + maxPoint.z - minPoint.z; } void merge ( const BoundingBox& box ) { if ( box.minPoint.x < minPoint.x ) minPoint.x = box.minPoint.x; if ( box.minPoint.у < minPoint.y ) minPoint.y = box.minPoint.y; if ( box.minPoint.z < minPoint.z ) minPoint.z = box.minPoint.z; if ( box.maxPoint.x > maxPoint.x ) maxPoint.x = box.maxPoint.x; if ( box.maxPoint.у > maxPoint.у ) maxPoint.у = box.maxPoint.у; if ( box.maxPoint.z > maxPoint.z ) maxPoint.z = box.maxPoint.z; void grow ( const Vector3D& delta ) { minPoint -= delta;
maxPoint += delta; } void grow ( float delta ) { minPoint.x -= delta; minPoint.у -= delta; minPoint.z -= delta; maxPoint.x -= delta; maxPoint.у -= delta; maxPoint.z -= delta; } void move ( const Vector3D& v ) { minPoint += v; maxPoint += v; } void apply ( const Transform3D& ); Метод addVertex позволяет добавить новую точку, корректируя при этом параметры ААВВ. Метод addVertices позволяет добавить сразу группу вершин и является просто оптимизированной версией метода addVertex. Метод classify служит для классификации ААВВ относительно плоскости. Для проверки точки на принадлежность служит метод contains. Метод isEmpty служит для проверки того, является ли заданное ААВВ пустым. Метод intersects служит для проверки двух ААВВ на пересечение. Метод reset служит для "обнуления" заданного ААВВ и обычно применяется перед построением ААВВ по набору точек. Метод getVertex позволяет получить координаты вершины ААВВ по ее индексу (от 0 до 7). Метод getCenter возвращает координаты центра данного ААВВ. Метод merge служит для объединения двух ААВВ. Методы grow, move и apply служат для применения заданных преобразований к ААВВ. Можно в качестве ограничивающих тел использовать прямоугольные параллелепипеды произвольной ориентации (Oriented Bounding Box-OBB): В = {с + ае, + Р<?2 + уе3, |а| < 1, |р| < 1, |у| < 1}, (3.9)
где с - центр параллелепипеда; е{, е2, е3 - ортогональные векторы, определяющие его оси. Если задан набор точек v0, v,,v„_j, то в качестве центра ОВВ можно выбрать их среднее I п-1 с = -Х^, (ЗЛО) п ,=о а векторы et,e2,e3 определяются как промасштабированные собственные векторы матрицы I 7 М =-£(vf-c)(v,-с) . (3.11) Несмотря на то что проверка попадания точки внутрь ОВВ довольно проста, хотя и сложнее, чем для ранее рассмотренных тел, проверка на пересечение двух ОВВ является достаточно сложной из-за того, что они могут иметь разные наборы осей. Один из алгоритмов для осуществления такой проверки основан на теореме о разделяющей оси - ищется такая прямая, чтобы проекции двух ОВВ на нее не пересекались бы. Проверяются следующие 15 осей: по 3 направляющие оси для каждого ОВВ и 9 осей, являющихся результатом векторного произведения этих осей между собой (рис. 3.11). Проверка на содержание одного ОВВ внутри другого также достаточно трудоемка. Объединение двух ОВВ может быть сделано достаточно просто - в качестве направляющих осей объединения достаточно взять направляющие оси одного из ОВВ.
Еще одним из простых и удобных в работе ограничивающих тел является так называемый k-DOP (Discrete Oriented Polytope') - пересечение областей, заключенных между набором параллельных плоскостей фиксированной ориентации (рис. 3.12). Направления нормалей для этих плоскостей заранее фиксируются, и поэтому для любых двух k-DOP нормали к соответствующим плоскостям совпадают. Поэтому работа с ними достаточно проста и сильно напоминает работу с ААВВ (на самом деле ААВВ является частным случаем k-DOP). Такой объект описывается следующей формулой: 5=п;л, Si={peRi:d^n<(p, nJ В этой формуле величины п, являются выбранными нормалями к плоскостям и фиксированы, a d™" и d™* определяют плоскости, между которыми лежит исходный объект, и, следовательно, зависят от него. Еще одной часто встречающейся задачей является проверка пересечения луча с заданными телами. Для начала мы рассмотрим пересечение луча с плоскостью (рис. 3.13).
Пусть задана плоскость л: (и, p) + d = 0 и луч г = org +1 • dir. Для начала продолжим луч до прямой и определим точку ее пересечения с плоскостью л. Для этого подставим уравнение общей точки луча в уравнение плоскости и отсюда найдем значение параметра, соответствующего точке пересечения: (и, org)+d (п, dir) (3.13) Видно, что полученная дробь имеет смысл, только когда |(и, «йг)| / 0 . Из соображений численной устойчивости последнее условие имеет смысл заменить на |(и, «йг)| > е . Если оно выполнено, мы считаем, что точка пересечения лежит на нашем луче и, следовательно, имеет место пересечение между лучом и плоскостью, точку пересечения находим из формулы (3.13). В случае, когда |(n, dir)\ < е, мы считаем, что луч не пересекает заданную плоскость. Луч мы будем далее описывать при помощи следующего класса: Н class Ray ( private: Vector3D org; // origin of ray Vector3D dir; // it's direction, always normalized publi c: Ray ( const Vector3D& theOrg, const Vector3D& theDir ) : org ( theOrg ), dir ( theDir ) ( dir.normalize (); ) const Vector3D& getOrigin () const ( return org; ) const Vector3D& getDir () const ( return dir; 1
Vector3D point ( float t ) const // point on ray { return org + t * dir; } float intersect ( const Plane& plane ) const; void transform ( const Transform3D& transf ); Одним из достаточно важных тестов с использованием луча является определение его пересечения с основными ограничивающими телами. Для начала мы рассмотрим определение пересечения луча со сферой (с, г) (рис. 3.14). Соответствующий код приводится ниже. Рис. 3.14 bool BoundingSphere :: intersect ( const Ray& ray ) const ( Vector3D 1 (center - ray.getOrigin ()); float d = 1 & ray.getDir (); float ISq =1*1; if ( d < 0 && ISq > radiusSq ) return false; float mSq = ISq - d*d; if ( mSq > radiusSquared ) return false; return true;
В случае, если пересечение луча со сферой действительно имеет место, то для нахождения расстояния вдоль луча до точки пересечения можно воспользоваться следующей формулой. _ d —q,l2 > г2, d + q,l2 < г2. Величина q определяется формулой q = -Jr2 -т2 , где т2 =l2-d2. (3.14) (3.15) (3.16) Достаточно просто осуществляется проверка пересечения луча с ААВВ, ОВВ и k-DOP. Каждый из этих объектов представляет собой пересечение участков пространства, заключенных между парами параллельных плоскостей. Поэтому достаточно найти пересечение луча с каждым из таких участков и найти их пересечение между собой. Рассмотрим это подробнее. Для каждой пары параллельных плоскостей л1! :(и,, р) + й/=0 и л1; : (/г,. p} + d2 = 0 находятся две точки пересечения луча с ними: t\ < t2. При этом если луч не имеет пересечения с одной из плоскостей, то в качестве значения параметра берется 0 или °°. Тогда если выполнено неравенство max t' < min t2, то пересечение имеет место (рис. 3.15, а) и параметром точки пересечения является max t'. В другом случае пересечения нет (рис. 3.15,6).
Проверка пересечения луча с многоугольником Еще одним часто встречающимся тестом является проверка луча на пересечение с заданным многоугольником. Первым шагом этого теста является проверка на пересечение луча с плоскостью, проходящей через этот многоугольник. Если пересечение имеет место, то находится его точка и проверяется принадлежность этой точки многоугольнику. Для этого достаточно спроектировать как сам многоугольник, так и проверяемую точку на одну из координатных плоскостей (обычно для минимизации численных погрешностей выбирается плоскость, соответствующая наибольшей по модулю компоненте вектора нормали к многоугольнику). Таким образом, проверка сводится к двухмерному тесту принадлежности точки многоугольнику. Эта задача обычно решается путем выпускания луча (на плоскости) из проверяемой точки п нахождения точек его пересечения с границей многоугольника. Исли оно нечетно (рис. 3.16), го точка лежит внутри, если нет -го снаружи. В качестве направления выпускаемого луча можно взять произвольный ненулевой вектор, но обычно из соображений удобства выбирают направление одной из координатных осей. Определенная осторожность требуется при прохождении точки через вершину (см. [ 10]). Ниже приводится код, осуществляющий подобную проверку. Рис. 3.16 юо! isPointlnside ( const Vector2D& p. const Vector2D * v bool inside = false; Vector2D el ( v [0] ); Vector2D eO ( v [n-1] ); bool yO = (eO.y >= p.y); or ( int i = 1; i < n; i++ ) bool yl = (el.у >= p.y); if ( yO != У1 ) int п ) if ( (el.y-p.y)*(eO.x-el.x) >= (el.x-p.x)*(eO.y-el.y)) == yl ) inside = !inside;
уО = yl; еО = el; el = v [i]; } return inside; 4 } ‘ Проверка пересечения двух многоугольников Еще одной часто встречающейся задачей является проверка на переселение двух выпуклых многоугольников. Ниже мы рассмотрим упрощенный случай пересечения двух треугольников. Пусть заданы два треугольника 1\(иоихи2) и (лежащие в плоскостях л, и л2 соответственно) и необходимо определит пересекаются ли они. Для начала определим коэффициенты плоскости л2: (n2, р)+J2 = 0. пг =[vi“vo’v2-vo]> d2=-(n2,v0). (3.17) Далее находятся расстояния со знаком ото всех вершин треугольника 7, до плоскости л2 (с точностью до постоянного множителя ||и21|2) путем подстановки вершин в уравнение плоскости. du, =(n2’Ui)+d2. (3.18) Если они все имеют одинаковый знак и отличны от нуля, то 7] целиком лежит по одну сторону л2 и пересечения нет. То же самое проделывается для Т2 и лг Это позволяет сразу же отбросить целый ряд часто встречающихся случаев. Если все du = 0, то треугольники 7] и Т2 лежат в одной плоскости, этот случай будет рассмотрен чуть позже. В противном случае находим пересечение плоскостей Л] и л2. Это будет прямая, задаваемая уравнением I = org +1 • dir, где dir = [и,, п2 ] -направляющий вектор этой прямой. Заметим, что оба треугольника пересекают эту прямую (в противном случае они были бы отброшены раньше) и в результате каждого пересечения получается отрезок на этой прямой. Эти отрезки пересекаются между собой тогда и только тогда, когда пересекаются исходные треугольники.
Рассмотрим, каким образом находится пересечение треугольника 7] и прямой (второе пересечение находится аналогично). Первым шагом будет проектирование вершин u0,ut,u2 на прямую ри = (dir,ul-org). (3.19) Далее мы будем считать, что вершины и0,и2 лежат по одну сторону от л2, а и, - по другую (рис. 3.17). Найдем пересечение ребра uout с прямой I = org +1 • dir. Для этого достаточно найти значение параметра t, соответствующее точке пересечения. Отрезок иои1 можно представить в параметрической форме как р = и0 + a(U( -и0),ае [0,1]. Тогда уравнение для точки пересечения ребра и прямой может быть записано как и0 +a(u, — u0) = org +1 • dir. (3.20)
Умножим это уравнение скалярно на п2. Так как точка пересечения лежит на обеих плоскостях, то (org +t-dir, п,) + d, = 0. Отсюда получаем уравнение для параметра а : </„ +a(du -du ) = 0. (3.21). Отсюда сразу же находится значение а. После чего оно подставляется в (3.20) и результат умножается скалярно на вектор dir, откуда получаем ^=Ло+(л,-Ло)т^- (3.22) Аналогичным образом находится значение параметра t2, соответствующее пересечению прямой ребра и1и2. Аналогично находится пересечение с прямой второго треугольника. Можно заметить, что если все значения концов получающихся отрезков сдвинуты на одну и ту же величину, то результат пересечения отрезков от этого не изменится. Это позволяет нам использовать вместо (3.19) упрощенную формулу Pu=(dir,Ui). (3.23) Реализация этого алгоритма для случая произвольных выпуклых многоугольников содержится в класса Polygon3D. Иерархические структуры Дальнейшее ускорение проверок между различными объектами может быть достигнуто за счет организации объектов в специальные структуры. Одним из наиболее распространенных типов подобных структур являются различные иерархические структуры (деревья). Часто встречающимся типом деревьев является дерево ограничивающих тел (Bounding Volume Hierarchy - BVH). Чаще всего такое дерево бывает двоичным (бинарным). Каждый узел такого дерева содержит в себе список объектов, ограничивающее тело для них и ссылки на дочерние узлы. Таким образом, узел подобного дерева может быть представлен при помощи следующего класса:
class BvhNode { protected: Array BoundingBox int BvhNode objects; box; numChildren; “node; При этом проверка какого-либо заданного объекта со всеми объектами дерева обычно начинается с корня. Если ограничивающее тело проверяемого объекта не пересекается с ограничивающим телом рассматриваемого узла, то пересечения нет и проверка на этом завершена (рис. 3.18). Рис. 3.18 В противном случае проверяется на пересечение каждый из дочерних узлов данного узла. При этом осуществляется спуск по дереву от корня к листьям. Когда проверка доходит до листа дерева, осуществляется явная проверка исходного объекта со всеми объектами, содержащимися в листе дерева. При этом мы автоматически отбрасываем все те объекты, которые шведомо не могут иметь пересечения с рассматриваемым объектом. В общем случае временные затраты при использовании иерархий составляют O(logn), где п - общее число объектов в иерархии. Ниже приводится пример кода, осуществляющего проверку на пересечение заданного объекта с иерархической структурой в виде BVH.
Е1 bool checkBvhNode ( BvhNode * node, Object * object ) { if ( 'object -> getBoundingBox ((.intersect ( node -> getBoundingBox () ) ) return false; if ( node -> isLeaf () ) return node -> checkobject ( object ); for ( int i = 0; i < node -> getNumChildren (); i++ ) if ( node -> getChild ( i ) != NULL ) if ( checkBvhNode ( node -> getChild ( i ), object ) ) return true; return false; Еще одним из вариантов дерева являются восьмеричные деревья (octree). В таком дереве каждый узел содержит до восьми потомков, которые строятся путем разбиения ограничивающего тела данного узла на 8 равных частей и определения объектов, содержащихся в каждой из таких частей. Далее мы подробно рассмотрим BSP-деревья, работу с ними и их построение. На прилагаемом к книге компакт-диске содержится исходный код для построения и работы с восьмеричными и одним из вариантов BSP-деревьев - kd-деревьями. Для работы с различными иерархическими структурами очень удобным оказывается паттерн "Посетитель" [12J. Область видимости Еще одним классом, который будет нам полезен в дальнейшем, является класс Frustrum, представляющий собой усеченную пирамиду (рис. 3.19). Рис. 3.19
Он задается ближней и дальней отсекающими плоскостями и набором плоскостей, проходящих через вершину пирамиды. Подобный объект очень удобен для моделирования области видимости наблюдателя и области пространства, видимой сквозь портал. Ниже приводится описание этого класса. Э class Frustrum { Vector3D org; // origin of frustrum Vector3D vertices [MAX_VERTICES]; // these vertices and org make the frustrum int numvertices; Plane * farPlane; Plane * nearPlane; Plane * planes [MAX_VERTICES]; // planes, build on vertices and org int numPlanes; public: Frustrum (); Frustrum ( const Vector3D& theOrg, int num, const Vector3D * v ); Frustrum ( const Frustrum& frustrum ); ( -Frustrum () } reset (); void set ( const Vector3D& theOrg, int num, const Vector3D * v ); Plane * getNearPlane () const { return nearPlane; } void setNearPlane ( const Plane& p ) { delete nearPlane; nearPlane = new Plane ( p ); } Plane * getFarPlane () const { return farPlane; }
void setFarPlane ( const Plane& p ) { delete farPlane; farPlane = new Plane ( p ); void reset () { delete farPlane; delete nearPlane; for ( int i = 0; i < numPlanes; i++ ) delete planes [i]; numPlanes = 0; farPlane = NULL; nearPlane = NULL; int getNumPlanes () const { return numPlanes; Plane * getPlane ( int index ) const { return index >= 0 && index < numPlanes ? planes [index] : NULL; bool contains ( const Vector3D& v ) const { for ( register int i = 0; i < numPlanes; i++ ) if ( planes [i] -> classify ( v ) == IN_BACK ) return false; if ( nearPlane != NULL ) if ( nearPlane -> classify ( v ) == IN_BACK ) return false; if ( farPlane != NULL ) if ( farPlane -> classify ( v ) == IN_BACK ) return false; return true;
bool contains ( const BoundingBox& box ) const { for ( register int i = 0; i < numPlanes; i++ ) if ( box.classify ( * planes [i] ) == IN_BACK ) return false; if ( nearPlane != NULL ) if ( box.classify ( *nearPlane ) == IN_BACK ) return false; if ( farPlane != NULL ) if ( box.classify ( *farPlane ) == IN_BACK ) return false; return true; } bool containslnside ( const BoundingBox& box ) const { for ( register int i = 0; i < numPlanes; i++ ) if ( box.classify ( *planes [i] ) != IN_FRONT ) return false; if ( box.classify ( *nearPlane ) != IN_FRONT ) return false; if ( box.classify ( *farPlane ) != IN_FRONT ) return false; return true; } bool isBlockedBy ( const Polygon3D& poly ) const; bool contains ( const Frustrum& ) const; void clipByPlane ( const Plane& ); void clipByFrustrum ( const Frustrum& ); private: void buildPlanes (); // build planes based on org // and vertices }; В силу того что подобная усеченная пирамида представляет собой пересечение конечного набора полупространств, все проверки для работы с ней сводятся к аналогичным проверкам для плоскостей.
Глава 4. ОСНОВЫ БИБЛИОТЕКИ OpenGL Практически вся графика в современных компьютерных играх делается с использованием трехмерных графических ускорителей, таких, как GeForce (2,3,4, FX), Radeon и др. Современные графические ускорители обладают большим набором возможностей и высокой производительностью, но при этом они часто имеют серьезные внутренние различия. Для того чтобы эффективно работать с такими ускорителями не привязываясь к особенностям какого-либо конкретного ускорителя, обычно применяются библиотеки, которые скрывают особенности конкретного ускорителя и предоставляют некоторый унифицированный интерфейс к нему. Обычно для достижения возможности работы с различными ускорителями используются драйверы устройств, через которые действует библиотека. При этом одна и та же программа будет успешно работать на ряде различных ускорителей без модификации исходного кода. На данный момент в мире персональных компьютеров существует два таких интерфейса: OpenGL, являющийся уже более 10 лет стандартом в мире рабочих станций, и Direct3D, предложенный и поддерживаемый компанией Microsoft. В отличие от OpenGL, который сразу разрабатывался для функционирования с графическими ускорителями, Direct3D был изначально ориентирован на программный {software) рендеринг. С тех пор он претерпел ряд серьезных изменений. Они облегчили работу с ним, но такая нестабильность по-прежнему остается очень серьезным фактором: может легко оказаться, что силы (и значительные, так как API Direct3D довольно громоздкий), потраченные на изучение очередной версии Direct3D, окажутся потраченными впустую, поскольку API опять изменится. Кроме того, Direct3D фактически не является стандартом в строгом смысле этого слова - это лишь некоторый интерфейс, объявленный и полностью контролируемый компанией Microsoft, которая может делать с ним все, что ей захочется и когда захочется. OpenGL, с другой стороны, представляет собой открытый стандарт, разработанный и утвержденный в 1992 г. девятью фирмами, среди который были Digital Equipment Corp., Evans & Sutherland, Hewlett Packard Co., IBM Corp, Intel Corp, Silicon Graphics Inc., Sun Microsystems и Microsoft.
В основу стандарта легла библиотека IRIS GL, разработанная фирмой Silicon Graphics Inc. OpenGL представляет собой открытый процедурный интерфейс к графическому ускорителю, позволяющий легко задавать объекты в пространстве и операции над ними. В настоящее время идет работа над стандартом OpenGL 2.0, который будет включать в себя, в частности, аппаратнонезависимые шейдеры и ряд других возможностей. С самого начала OpenGL разрабатывался как эффективный, аппаратно-и платформенно-независимый интерфейс. Он не включает в себя специальных команд, привязанных к какой-либо конкретной операционной системе, таких, как работа с окнами и организация ввода-вывода. Для этого существуют дополнительные библиотеки, одну из них - glut - мы рассмотрим далее. Библиотека OpenGL позволяет легко создавать объекты из геометрических примитивов (точек, линий, граней), располагать их в трехмерном пространстве, выбирать способ и параметры проектирования, вычислять цвета пикселов с использованием текстур и источников света. Поскольку OpenGL разрабатывался как открытый стандарт, то производители графичесих ускорителей легко могут добавлять в него свои функции, реализующие дополнительные возможности, например такие, как пик-селовые и вершинные шейдеры. Подробнее обсуждение таких шейдеров будет содержаться во второй части работы. OpenGL o6£i4Ho реализуется с использованием модели клиент-сервер. Приложение выступает в роли клиента - оно генерирует команды, а сервер OpenGL выполняет их. При этом сам сервер может находиться на другом компьютере. Хотя библиотека и поддерживает палитровые режимы, далее мы будем считать, что работа ведется в режиме непосредственного задания цвета (Hi-Color или TrueColor). Все команды (процедуры и функции) OpenGL начинаются с префикса gl, и все константы - с префикса GL_. Кроме того, в имена функций, и процедур OpenGL входят суффиксы, несущие информацию о числе передаваемых параметров и их типе. В табл. 4.1 приводятся вводимые OpenGL типы данных, каким стандартным типам языка С они соответствуют и какие суффиксы им соответствуют. Таблица 4.1 Суффикс Описание Тип в С Типы в OpenGL b 8-битовое целое signed char GLbyte S 16-битовое целое short GLshort
Суффикс Описание Тип в С Типы e OpenGL i 32-битовое целое long GLint, GLsizei f 32-битовое число с плавающей точкой float GLfloat, GLclampf d 64-битовое число с плавающей точкой double GLdouble, GLclampd ub 8-битовое беззнаковое целое unsigned char GLubyte, GLboolean US 16-битовое беззнаковое целое unsigned short GLushort ui 32-битовое беззнаковое целое unsigned long GLUint, GLenum, GLbitfield void GLvoid Некоторые команды OpenGL оканчиваются на букву v, что говорит о том, что команда получает указатель на массив значений, а не сами эти значения в виде отдельных параметров. Многие команды имеют как векторные, так и невекторные версии, например конструкции glColor3f (1.0, 1.0, 1.0); и GLfloat color [] = { 1.0, 1.0, 1.0 }; glColor3fv ( color ) ; полностью эквивалентны. OpenGL можно рассматривать как машину, находящуюся в одном из нескольких состояний (Finite State Machine, FSM). Внутри OpenGL содержится целый ряд переменных, например текущий цвет, текущее значение вектора нормали, способ наложения текстуры и т. п. Можно установить текущий цвет, и все последующие объекты будут использовать этот цвет, до тех пор, пока текущий цвет не будет изменен. Каждая системная переменная имеет свое значение по умолчанию и в любой момент времени можно узнать значение каждой из этих переменных. Обычно для этого используется одна из следующих функций: glGetBooleanv, glGetDoublev, glGet-Floatv и glGetlntergerv. Для определения значений некоторых переменных служат специальные функции.
Использование библиотеки glut Для облегчения работы с OpenGL, и в частности работы с окнами и вводом, удобно использовать библиотеку glut. Эта кросс-платформенная библиотека позволяет легко создавать переносимые приложения, использующие OpenGL. Библиотека glut (OpenGL Utility Toolkit) является прозрачным интерфейсом для написания переносимых программ, использующих OpenGL, и взаимодействующих с оконной системой. Она позволяет легко писать переносимые программы на ряде языков, включая С и C++. Существуют версии glut для X Window, Windows и Mac OS X. Для написания простейшей программы с помощью glut нужно знание всего нескольких простых функций, требующих небольшого числа параметров. Здесь мы рассмотрим лишь некоторое подмножество glut, которое позволит создать окно для вывода изображений средствами OpenGL и обрабатывать ввод информации от пользователя. По аналогии с OpenGL каждая функция этой библиотеки начинается с префикса glut. Первым шагом при работе с glut является инициализация, для чего служат функции, имена которых начинаются с префикса glutlnit. Эти функции должны вызываться до вызова каких-либо других функций glut или OpenGL. Начальная инициализация glut осуществляется при помощи вызова функции void glutlnit ( int * argcp, char ** argv ); где argcp - указатель на неизмененную переменную argc из функции main. По возвращении из функции список аргументов может быть изменен, так как библиотека обрабатывает переданные ей в командной строке параметры и удаляет их из списка. Величина argv является параметром argv функции main. Для задания начального положения окна и его размеров служат функции void glutlnitWindowPos ( int х, int у ); void glutlnitWIndowSize ( int width, int height ); Здесь величины x, у, width и height задаются в пикселах. Видеорежим задается при помощи функции void glutlnitDisplayMode ( unsigned mode ); где параметр mode, задающий параметры режима, является логическим объединением следующих флагов (табл. 4.2).
Таблица 4.2 Значение Комментарий GLUT-RGBA Выбор режима RGBA GLUT.RGB To же, что и GLUT_RGBA GLUT-INDEX Палитровый видеорежим GLUT-SINGLE Использование одинарного видеобуфера GLUT-DOUBLE Использование двойной буферизации, применяется для создания анимации GLUT.ACCUM Создавать аккумулирующий буфер GLUT-ALPHA Создавать окно с а-каналом GLUT-DEPTH Создавать окно с буфером глубииы GLUT_STENCIL Создавать окно с буфером трафарета После инициализации можно создать окно, в которое будет происходить вывод при помощи функции int glutCreateWindow ( char * name ); Параметр name задает заголовок окна. Функция возвращает целочисленный идентификатор окна. Он может быть использован в функции glut-SetWindow. После того как окно (или несколько окон) было создано, можно вызвать функцию void glutMainLoop (); которая запускает цикл обработки сообщений. После вызова этой функции управление назад зэке не возвращается. Поэтому если есть необходимость в какой-либо обработке поступающего ввода, то необходимо установить обработчики событий. Основными событиями, явная обработка которых может Потребоваться, являются перерисовка содержимого окна, изменение размеров окна, иажа-тие-отпускание клавиш клавиатуры и мыши, перемещение мыши. Для установки обработчика события, связанного с необходимостью перерисовки содержимого окна, используется функция void glutDisplayFunc ( void (*func)() ); Параметр func задает функцию, которая будет вызываться для перерисовки содержимого окна.
Для установки обработчика события, связанного с изменением размера окна, служит функция void glutReshapeFunc ( void (*func)(int width, int height) ); Параметр func задает функция, которая будет вызываться при изменении размеров окна перед вызовом функции отрисовки содержимого окна. В качестве параметров функция получает новые значения ширины и высоты окна в пикселах. Для установки обработчика сообщений от клавиатуры служит функция void glutKeyboardFunc (void (*func)(unsigned char key, int x, int y)); Параметр key содержит ASCII-код символа, а параметры x и у - координаты курсора мыши в пикселах по отношению в верхнему левому углу окна. Обработчики сообщений от мыши устанавливаются функцией void glutMouseFunc ( void (*func)(int button, int state, int x, int y)); Параметр button принимает одно из значений GLUT LEFT BUTTON, GLUT_MIDDLE_BUTTON или GLUT RIGHT BUTTON и несет в себе информацию о том, какая клавиша мыши была нажата (или отпущена). Параметр state принимает одно из значений GLUT DOWN (клавиша нажата) или GLUT UP (клавиша отпущена). Параметры х и у содержат координаты курсора мыши. Вызов обработчика, установленного glutMouseFunc, происходит лишь при нажатии или отпускании клавиши мыши. Если необходимо установить обработчик на перемещение мыши, то для этого следует воспользоваться функциями void glutmotionfunc ( void (*func)(int x, int у ) ); void glutPassiveMotionFunc ( void (*func)(int x, int y) ); Первая из них устанавливает обработчик, который будет вызываться при перемещении мыши при нажатой клавише (любой из имеющихся) мыши, а вторая - при передвижении мыши в том случае, когда ни одна из клавиш мыши не нажата. Обработчик клавиш, не имеющих ASCII-кодов, можно задать при помощи функции void glutSpecialFunc ( void (*func)(int key, int x, int y) );
Параметр key несет в себе информацию о нажатой клавише и может принимать одно из следующих значений: GLUT_KEY_F1, GLUT_KEY_F2, GLUT_KEY_F3, GLUT_KEY_F4, GLUT_KEY_F5, GLUT_KEY_F6, GLUT. KEY_F7, GLUT_KEY_F8, GLUT_KEY_F9, GLUT_KEY_F10, GLUT_KEY_F11, GLUT_KEY_F12, GLUT_KEY_LEFT, GLUT_KEY_RIGHT, GLUT_KEY_UP, GLUT_KEY_DOWN, GLUTJPAGEJJP, GLUT_PAGE_DOWN, GLUT_HOME, GLUT.END, GLUT-INSERT. Клавиши Esc, BS и Delete имеют ASCII-коды, и их обработчик устанавливается функцией glutKeyboardFunc. Обработчик, который постоянно будет вызываться (например, для анимации), можно установить при помощи функции void glutldleFunc ( void (*func)() ); При использовании двойной буферизации для смеиы буферов местами (буфера, содержимое которого видно сейчас на экране, и буфера, в который производится вывод) служит функция: void glutSwapBuffers (); Для получения информации о внутренних переменных glut можно использовать следующую функцию int glutGet (GLEnum state ); Здесь величина state может принимать такие значения: GLUT_WINDOW_X - для возврата х-координаты окна; GLUT_W1NDOW_Y - для возврата у-координаты окна; GLUT_WINDOW_WIDTH - для возврата ширины окна; GLUT_WINDOW_HEIGHT - для возврата высоты окна; GLUT_ELAPSED_TIME - для возврата времени в миллисекундах с момента инициализации glut. Также glut поддерживает вывод ряда стандартных трехмерных объектов, таких, как прямоугольные параллелепипеды, сферы, торы, пирамиды и многое другое. Конечно, библиотека glut содержит гораздо больше функций, чем приведено здесь. Полное описание glut, а также сами файлы для работы с ней можно найти на официальном сайте библиотеки OpenGL www.opengl.org. Ниже приводится пример простейшей программы с использованием glut.
S) #include <gl/glut.h> #include <stdio.h> void init () { glClearColor ( 0.0, 0.0, 0.0, 1.0 ); glEnable ( GL_DEPTH_TEST ); void display () { giciear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT ); glutSwapBuffers (); void reshape ( int w, int h ) { glViewport ( 0, 0, (GLsizei)w, (GLsizei)h ); glMatrixMode ( GL_PROJECTION ); glLoadldentity (); glMatrixMode ( GL_MODELVIEW ); glLoadldentity (); void key ( unsigned char key, int x, int у ) { if ( key ==27 || key == 'q' || key == 'Q' ) exit ( 0 ); // quit requested int main ( int argc, char** argv ) { // initialise glut glutlnit ( barge, argv ); glutlnitDisplayMode ( GLUT_DOUBLE | GLUT_RGB | GLUTJDEPTH ); glutlnitWindowSize ( 400, 400 ); // create window int glWin = glutCreateWindow ( "OpenGL example 1“ ); init(); // register handlers glutDisplayFunc ( display );
glutReshapeFunc ( reshape ); glutKeyboardFunc ( key ); glutMainLoop (); return 0; } Данная программа создает окно размером 400 на 400 пикселов с поддержкой двойной буферизации и буфера глубины, после чего очищает его. Рисование геометрических объектов OpenGL содержит внутри себя несколько различных буферов. Среди них фрейм-буфер (куда производится построение изображения), г-буфер, служащий для удаления невидимых поверхностей, буфер трафарета и аккумулирующий буфер (рис. 4.1) Для очистки окна (экрана, внутренних буферов) служит процедура void glclear ( GLbitfield mask ); очищающая буферы, заданные переменной mask. Параметр mask является комбинацией следующих констант: GL_COLOR_BUFFER_BIT - очистить буфер изображения (фрейм-буфер); GLJDEPTHJBUFFERJBIT - очистить г-буфер; GL_ACCUM_BUFFER_BIT - очистить аккумулирующий буфер; GL_STENCIL_BUFFER_BIT- очистить буфер трафарета. При этом цвет, которым очищается буфер изображения, задается процедурой void glClearColor ( GLclampf read, GLclampf green, GLclampf blue, GLclampf alpha ); Значение, записываемое в г-буфер при очистке, задается процедурой void glClearDepth ( GLclampd depth );
Значение, записываемое в буфер трафарета, задается процедурой void glClearStencil ( GLint s ); Цвет, записываемый в аккумулирующий буфер, задается процедурой void glClearAccum ( GLfloat red, GLfloat green, GLfloat blue, GLfloat alpha ); При этом сама команда glClear очищает одновременно все заданные буферы, заполняя их соответствующими значениями. Для задания цвета объекта служит процедура void glColor3{b s i void glColor4{b s i void glColor3{b s i void glColor4{b s i f d ub us uiHTYPE r,TYPE g,TYPE b ); f d ub us ui}(TYPE r.TYPE g,TYPE b, TYPE a ); f d ub us ui}v(const TYPE * v ); f d ub us ui}v(const TYPE * v ); Если a-значение не задано, то оно автоматически кладется равным единице. Версии процедуры glColor*, где параметры являются переменными с плавающей точкой, автоматически обрезают переданные значения в отрезок [0, 1]. Значения остальных типов приводятся (масштабируются) в этот отрезок для беззнаковых типов (при этом наибольшему возможному значению соответствует значение, равное единице) и в отрезок [-1,1] для типов со знаком. Процедура void glFlush (); вызывает немедленное выполнение ранее переданных серверу команд. Если нужно включить удаление невидимых поверхностей методом г-буфера, то необходимо очистить г-буфер и подать команду glEnable ( GL_DEPTH_TEST ); Все геометрические примитивы задаются в терминах вершин. Каждая вершина задается набором чисел. OpenGL работает с однородными координатами (х, у, г, и»). Если координата г не задана, то она кладется равной нулю. Если координата w не задана, то она кладется равной единице. Библиотека OpenGL может выводить точки, линии, полигоны и битовые изображения. Под линией в OpenGL подразумевается отрезок, заданный своими начальной и конечной вершинами.
Под гранью (многоугольником) в OpenGL подразумевается замкнутый выпуклый многоугольник с несамопересекающейся границей. Все геометрические объекты в OpenGL задаются посредством вершин, а сами вершины задаются процедурой void glVertex{2 3 4}{s i f d}{v}( TYPE x, ... ); где реальное количество аргументов определяется первым суффиксом (2, 3 или 4) и суффикс v означает, что в качестве единственного аргумента выступает массив, содержащий необходимое количество координат. Например: glVertex2s ( glVertext3f ( 1, 2 ); 2.3, 1.5, 0.2 ); [] ={ 1.0, 2.0, 3.0, 4.0}; GLdouble vect glVertext4dv ( vect ); Для задания геометрических примитивов необходимо как-то выделить набор вершин, задающий этот объект. Для этого служат процедуры glBegin и glEnd. Процедура void glBegin ( GLenum mode ); обозначает начало списка вершин, описывающих геометрический примитив. Тип примитива задается параметром mode, который принимает одно из следующих значений (рис. 4.2): GLJPOINTS GL.LINES GL_LINE_STRIP GL_LINE_LOOP GL_POLYGON GL_TRIANGLES - набор отдельных точек; - пары вершин, задающие отдельные отрезки: v,],[v2,v3] т.д.; - незамкнутая ломаная v0v,v2...vn; - замкнутая ломаная vov,v2...vBvo; - простой выпуклый многоугольник; - тройки вершин, интерпретируемые как вершины отдельных треугольников: v0v(v2, v3v4v5,...; GL_TRIANGLE_STRIP- связанная полоса треугольников: V'o''1V2’V2V1V3’V2V3V4’-; - веер треугольников: vov,v2, vov2v3, v0v3v4,...; - четверки вершин, задающие выпуклые четырехугольники: vov(v2v3, v4v5v6v7,...; - полоса четырехугольников: V0VlV3V2> v2v3v5v4, v4v5v7v6,... GL_TRIANGLE_FAN GL_QUADS GL_QUAD_STRIP
GLJJNES GL_LINE_STRIP GL_LINE_LOOP GL POINTS GL_QUAD_STRIP GL TRIANGLES GL_TRIANGLE_STRIP GL_TRIANGLE_FAN Puc. 4.2 Процедура void glEnd (); отмечает конец списка вершин. Между командами glBegin и glEnd могут находиться следующие команды: gIVertex*, glColor*, glNonnal*, glCallList, glCallLists, glTexCoord*, glEdgeFlag и glMateiial*. Все остальные команды OpenGL недопустимы между командами glBegin и glEnd и приведут к возникновению ошибки. По команде glEnd осуществляется вывод текущего объекта. Рассмотрим в качестве примера задание окружности при помощи правильного многоугольника.
glBegin ( GL_LINE_LOOP ); for ( int i = 0; i < N; i++ ) { float angle =2*M_PI*i/N; glVertex2f ( cos ( angle ), sin ( angle ) ); } glEnd (); Хотя многие команды могут находиться между glBegin и glEnd, вершины генерируются при вызове glVertex*. В момент вызова glVertex* OpenGL присваивает создаваемой вершине текущий цвет, координаты текстуры, вектор нормали и т. д. Рисование точек, линий и многоугольников Для задания размеров точки служит процедура void glPointSize ( GLfloat size ); которая устанавливает размер точки в пикселах, по умолчанию размер точки равен единице. Для задания ширины линии в пикселах служит процедура void glLineWidth ( GLfloat width ); Можно задать шаблон, которым будет рисоваться линия, при помощи процедуры void glLineStipple ( GLint factor, GLushort pattern ); Шаблон задается переменной pattern, и он растягивается в factor раз. Для использования шаблонов линий необходимо разрешить применение шаблонов линий при помощи команды glEnable ( GL_LINE_STIPPLE ) ; Запретить использование шаблонов линий можно командой glDisable ( GL_LINE_STIPPLE ) ; Многоугольники рисуются как заполненные области пикселов внутри границы, хотя их можно рисовать либо только как граничную линию, либо просто как набор граничных вершин.
Многоугольник имеет две стороны - переднюю и заднюю - и может быть отрисован по-разному в зависимости от того, какая сторона обращена к наблюдателю. По умолчанию обе стороны рисуются одинаково. Для задания того, как следует рисовать переднюю и заднюю стороны многоугольника, служит процедура void glPolygonMode ( GLenum face, GLenum mode ); Параметр face может принимать значения GL_FRONT_AND_BACK (обе стороны), GLJFRONT (передняя сторона) или GL_BACK (задняя сторона), параметр mode может принимать значения GLJPOINT, GL_UNE или GL_FILL, обозначая, что многоугольник должен рисоваться как набор граничных точек, замкнутая ломаная или как заполненная область, например glPolygonMode ( GL_FRONT, GL_FILL ); glPolygonMode ( GL_BACK, GL_LINE ); По умолчанию многоугольник, чьи вершины появляются на экране в направлении против часовой стрелки, называются лицевыми (передними) (рис. 4.3). Это можно изменить Рис. 4.3 при помощи процедуры void glFrontFace ( GLenum mode ); По умолчанию mode равняется GL_CCW, что соответствует направлению обхода против часовой стрелки. Если задать этот параметр равным GL_CW, то лицевыми будут считаться многоугольники с направлением обхода вершин по часовой стрелке. При помощи процедуры void glCullFace ( GLenum mode ); можно запретить вывод лицевых или нелицевых многоугольников. Параметр mode принимает одно из значений GL_FRONT (оставить только лицевые грани), GL_BACK (оставить нелицевые) или GL_FRONT_AND_BACK (оставить все грани). Для отсечения граней Необходимо разрешить отсечение при помощи команды glEnable ( GL_CULL_FACE );
Можно задать шаблон для заполнения грани при помощи процедуры void glPolygonStipple ( const GLubyte * mask ); где mask задает массив битов размером 32 на 32. Для разрешения использования шаблонов при выводе многоугольников служит команда glEnable ( GL_POLYGON_STIPPLE ); Для каждой вершины можно задать свой вектор нормали при помощи одной из следующих процедур: void glNormal3{b s i d f) ( TYPE nx, TYPE ny, TYPE nz ); void glNormal3(b s i d f}v ( const TYPE * v ); В версиях с суффиксом b, s или i значения аргументов масштабируются в отрезок [-1, 1]. В качестве примера приведем процедуру, строящую прямоугольный параллелепипед с ребрами, параллельными координатным осям, по координатам диапазонов изменения х, у и z- Э void drawBox ( GLfloat xl, GLfloat x2, GLfloat yl, GLfloat yl, GLfloat zl, GLfloat z2 ) { glBegin ( GL_POLYGON ); // front face glNormal3f ( 0.0, 0.0, 1.0 glVertex3f ( xl. yl, z2 ); glVertex3f ( x2, yl, z2 ) ; glVertex3f ( x2, y2, z2 ) ; glVertex3f ( xl, y2, z2 ) ; glEnd (); glBegin ( GL_POLYGON ); // back face glNormal3f ( 0.0, 0.0, -1.0 ); glVertex3f ( x2, yl, zl ); glVertex3f ( xl, yl, zl ) ; glVertex3f ( xl, y2, zl ); glVertex3f ( x2, y2, zl ); glEnd (); glBegin ( GL_POLYGON ); // left face glNormal3f ( -1. 0, 0 .0, 0.0 ) glVertex3 f ( xl, yi, zl ) ; glVertex3f ( xl, У1, z2 ) ; glVertex3 f ( xl, У2, z2 ) ; glVertex3 f ( xl, У2, zl ); glEnd ();
glBegin ( GL.POLYGON ); // right face glNormal3f ( glVertex3f ( glVertex3f ( glVertex3f glVertex3f ( glEnd (); 1.0, 0.0, 0.0 ); x2, yl, z2 ); x2, yl, z1 ); ( x2, y2, zl ) ; x2, y2, z2 ) ; glBegin ( GL_POLYGON ) ; // top face glNorma!3f ( 0.0, 1. 0, 0.0 )'; glVertex3f ( xl, y2, z2 ) ; glVertex3f ( x2, y2, z2 ) ; glVertex3f ( x2, y2, Zl ); glVertex3f ( xl, у2, zl ); glEnd (); glBegin ( GL_POLYGON ); // bottom face glNormal3f ( 0.0, -1 .0, 0.0 ) glVertex3f ( x2. yi, z2 ) ; glVertex3f ( xl, yl, z2 ) ; glVertex3f ( xl. yl, zl ) ; glVertex3f ( x2, yl, zl ); glEnd (); Преобразование объектов в пространстве. Камера В процессе построения изображения координаты вершин подвергаются следующим преобразованиям (рис. 4.4): Рис. 4.4 Подобным преобразованиям подвергаются также заданные векторы нормали.
По умолчанию камера изначально находится в начале координат й направлена вдоль отрицательного направления оси Oz. В OpenGL существует две матрицы, последовательно применяющиеся в преобразовании координат точки. Одна из них - это матрица моделирования (modelview matrix), а другая - матрица проектирования (projection matrix). Первая из них служит для задания положения объекта и его ориентации, а вторая отвечает за выбранный способ проектирования. Также еще существует матрица преобразования текстурных координат (texture mtitrix). Все координаты (включая текстурные) внутри OpenGL хранятся при помощи однородных координат, т. е. виде четырехмерных векторов (х, у, z, w). Преобразования также описываются при помощи матриц однородных преобразований следующим образом: Существует набор различных процедур, умножающих текущую матрицу (матрицу моделирования или проектирования) на матрицу выбранного геометрического преобразования. Текущая матрица задается при помощи процедуры void glMatrixMode ( Gbenum mode ); Параметр mode может принимать значения GL_MODELVIEW, GL_PROJECTION или GL_TEXTURE, позволяя выбирать в качестве текущей матрицы матрицу моделирования, матрицу проектирования или матрицу преобразования текстуры. Процедура void glLoadldenity (); устанавливает текущую матрицу равной единичной. Обычно задание соответствующей матрицы начинается с установки ее в единичную, а затем в последовательном применении матриц геометрических преобразований. Преобразование переноса задается процедурой void glTranslate {fd}(TYPE x, TYPE y, TYPE z) ; обеспечивающей сдвиг объекта на величину (х, у, z). Преобразование поворота задается процедурой
void glRotate {fd}(TYPE angle, TYPE x, TYPE y, TYPE z); обеспечивающей поворот на угол angle в направлении против часовой стрелки вокруг прямой с направляющим вектором (х, у, z). Преобразование масштабирования задается процедурой void glScale {fd)(TYPE х, TYPE у, TYPE z) ; Если последовательно указано несколько преобразований, то в результате текущая матрица будет, последовательно умножена на матрицы соответствующих преобразований. Рассмотрим, каким образом можно построить изображение руки робота, представленное на рис. 4.5. Рука состоит из двух боковых опор и трех последовательно сочлененных фрагментов. Для построения этого объекта воспользуемся процедурой drawBox, приведенной ранее. Рис. 4.5 Э void drawArm () { glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT ); glMatrixMode ( GL_MODELVIEW ); glLoadldentity (); glTranslatef ( 0.0, 0.0, -64.0 ); glRotatef ( 3Q.0, 1.0, 0.0, 0.0 ); glRotatef ( 30.0, 0.0, 1.0, 0.0 ) ; /X draw the block anchoring the arm drawBox drawBox ( 1.0, 3.0, -2.0, 2.0, -2.0, 2.0 ); ( -3.0, -1.0, -2.0, 2.0, -2.0, 2.0 ) ; glRotatef drawBox // // rotate the coordinate system and draw the arm's base member ( (GLfloat) angle, 1.0, 0.0, 0.0 ) ; ( -1.0, 1.0, -1.0, 1.0, -5.0, 5.0 ); // J1 // glTranslatef glRotatef drawBox translate the coordinate system the end of base member, rotate and draw the second member ( 0.0, 0.0, -5.0 ); ( -(GLfloat) angle / 2.0, 1.0, ( -1.0, 1.0, -1.0, 1.0, -10.0, to it, 0.0, 0.0 ) 0.0 ) ;
// translate and rotate coordinate // system again and draw arm's ll third member glTranslatef ( 0.0, 0.0, -5.0 ); glRotatef ( -(GLfloat) angle / 2.0, 1.0, 0.0, 0.0 ); drawBox ( -1.0, 1.0, -1.0, 1.0, -10.0, 0.0 ); Для начала инициализируется моделирующая матрица и локальная система координат переносится в точку, которая будет служит опорной Точкой для руки. glMatrixMode ( GL_MODELVIEW ); glLoadldentity О; glTranslatef ( 0.0, 0.0, -64.0 ); / glRotatef ( 30.0, 1.0, 0.0, 0.0 ); glRotatef ( 30.0, 0.0, 1.0, 0.0 ); Далее рисуются два опорных блока, по обе стороны от опорной точки. drawBox ( 1.0, 3.0, -2.0, 2.0, -2.0, 2.0 ); drawBox ( -3.0, -1.0, -2.0, 2.0, -2.0, 2.0 ); Далее осуществляется поворот локальной системы координат на угол angle вокруг оси Ох и рисуется первый блок руки. glRotatef ( (GLfloat) angle, 1.0, 0.0, 0.0 ) ; drawBox ( -1.0, 1.0, -1.0, 1.0, -5.0, 5.0 ); Перед рисованием следующего блока начало локальной системы координат переносится из центра первого блока на 5 единиц в отрицательном направлении оси Oz. Это помещает центр локальной системы координат в конце первого члена руки. Это есть опорная точка для второго члена -точка, где первый и второй блоки сочленяются. Затем локальная система координат поворачивается вокруг оси Ох и рисуется следующий блок. glTranslatef ( 0.О, 0.0, -5.0 ); glRotatef ( -(GLfloat) angle / 2.0, 1.0, 0.0, 0.0 ); drawBox ( -1.0, 1.0, -1.0, 1.0, -10.0, 0.0 ); Команды для рисования третьего члена аналогичны. glTranslatef ( 0.0, 0.0, -5.0 ); glRotatef ( -(GLfloat) angle / 2.0, 1.0, 0.0, 0.0 ); drawBox ( -1.0, 1.0, -1.0, 1.0, -10.0, 0.0 ); Чтобы задаваемые объекты могли быть нарисованы, необходимо задать способ проектирования; OpenGL поддерживает два вида проектирования -параллельное и перспективное.
Преобразование проектирования задает, как объекты будут проектироваться на экран и какие части объектов будут отсечены как не попадающие в поле зрения. Для задания матрицы проектирования сначала надо выполнить следующие команды: glMatrixMode ( GL_PROJECTION ); glLoadldentity (); Поле зрения при перспективном преобразовании является усеченной пирамидой (рис. 4.6). дура void glFrustrum ( GLdouble left, GLdouble right, GLdouble top, GLdouble bottom, GLdoubel near, GLdouble far ) ; Смысл передаваемых параметров ясен из рисунка. Обратите внимание на то, что в момент применения матрицы проектирования координаты объектов уже переведены в систему координат камеры. Величины near и far должны быть неотрицательными. При этом текущая матрица проектирования умножается на следующую матрицу:
' 2•near 0 right + left A A right-left right-left 0 2-near top + bottom 0 top-bottom top - bottom (4.2) 0 0 far + near 2 • far • near far-near far - near [ 0 0 -1 0 J Иногда для задания перспективного преобразования удобнее воспользоваться следующей процедурой из библиотеки утилит OpenGL (все функции этой библиотеки начинаются с префикса g/и). void gluPerspective ( GLdouble fovy, GLdouble aspect, GLdouble zNear, GLdouble zFar ); Эта процедура создает матрицу для задания симметричного поля зрения и умножает текущую матрицу на нее. При этом fovy - угол зрения камеры в плоскости Oxz, он должен лежать в диапазоне [0, 180]. Параметр aspect есть отношение ширины области к ее высоте; zNear и zFar - расстояния вдоль отрицательного направления оси Oz, определяющие ближнюю и дальнюю плоскости отсечения. Соответствующая матрица проектирования имеет следующий вид: r f 0 0 0 aspect 0 f 0 far + near 0 2 • far • near (4.3) 0 0 near - far near - far 0 0 -1 0 где j- fovy f ~ ctg (4.4) 2 В случае параллельного проектирования полем зрения является прямоугольный параллелепипед. Для задания параллельного проектирования служит процедура void glOrtho ( GLdouble left, GLdouble right, GLdouble bottom, GLdouble top, GLdouble near, GLdouble far ) ;
Матрица проектирования в этом случае умножается на матрицу 2 0 0 right + left right-left right - left 0 2 0 top + bottom top-bottom top-bottom (4-5) - 0 0 -2 far + near far - near far-near 0 0 0 1 Следующим шагом в задании проектирования после выбора параллельного или перспективного преобразования является задание области в окне, в которую будет помещено получаемое изображение. Для этого служит процедура void glViewport ( GLint х, GLint у, GLsizei width, GLsizei height ); Здесь (x, у) задает нижний левый угол прямоугольной области в окне, a width и height являются ее шириной и высотой. На самом деле моделирующая, проектирующая и матрица преобразования текстуры организованы в стеки. При этом работа всегда осуществляется с матрицей, лежащей на вершине соответствующего стека. Для помещения текущей матрицы в стек служит процедура void glPushMatrix () ; - . Для снятия матрицы со стека служит процедура void glPopMatrix () ; Ниже приводится пример программы для построения изображения машины с колесами (причем каждое колесо крепится пятью болтами), использующей работу со стеком матриц. S' void drawWhellAndBolts () { drawwheel (); for ( int i = 0; i < 5; i++ ) { glPushMatrix (); glRotatef ( 72.0*i, 0.0, 0.0, 1.0 );
glTranslatef ( 3.0, 0.0, 0.0 ); drawBolt (); glPopMatrix (); } } void drawBodyAndWheelAndBolts () { drawCarBody (); glPushMatrix (); glTranslatef ( 40.0, 0.0, 30.0 ); drawWheelAndBolts (),-glPopMatrix (); glPushMatrix (); glTranslatef ( 40.0, 0.0, -30.0 ); drawWheelAndBolts (); glPopMatrix (); .............. // draw last 2 wheels similarly } Приведем пример построения анимации с использованием библиотеки glut, а void init () { glClearColor ( 0.0, 0.0, 0.0, 1.0 ); glEnable ( GL_DEPTH_TEST ); } void animate () { angle += 0.5f; glutPostRedisplay (); } void display () { glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT ); glPushMatrix (); glTranslatef ( 1.5f, 1.5f, 1.5f ) ; // move cube from the center
glRotatef ( angle, l.Of, l.Of, O.Of glTranslatef ( -1.5f, -1.5f, -1.5f ),-// move cube into the center drawBox (. 1, 2, 1, 2, 1, 2 ) ; glPopMatrix () ; glutSwapBuffers () ; void reshape ( int w, int h ) { glViewport ( 0, 0, (GLsizei)w, (GLsizei)h ); glMatrixMode ( GL_PROJECTION ); glLoadldentity (); gluPerspective ( 60.0, (GLfloat)w/(GLfloat)h, 1.0, 60.0 ) ; glMatrixMode ( GL_MODELVIEW ); glLoadldentity (); gluLookAt . (0.0, 0.0, 0.0, // eye // center } 1.0, 0.0, 1.0, 1.0, 1.0, 0.0 ); void key ( unsigned char key, int x, int у ) { if ( key ==27 || key == 'q' || key == 'Q* ) exit ( 0 ); 11 quit requested } int main ( int argc, char** argv ) { // initialise glut glutlnit ( &argc, argv ); glutlnitDisplayMode ( GLOT_POUBLE | GLUT_RGB | GLUT_DEPTH ); glutlnitWindowSize ( 400, 400 ) ; // create window int GLwin = glutCreatewindow ( "OpenGL example 3" ); init(); // register handlers glutDisplayFunc ( display ); Ш
glutReshapeFunc ( reshape ); glutKeyboardFunc ( key ); glutldleFunc ( animate ); glutMainLoop (); return 0; } Дисплейные списки В традиционных языках программирования существуют функции и процедуры - т. е. можно выделить определенный набор команд, запомнить его в некотором одном определенном месте и вызывать каждый раз, когда возникает потребность в соответствующей последовательности команд. Подобная возможность существует и в OpenGL - набор команд OpenGL можно запомнить в так называемый дисплейный список (display list), при этом все команды и данные переводятся в некоторое внутреннее представление, наиболее удобное для данной реализации OpenGL, и затем вызвать при помощи всего одной команды. Каждому дисплейному списку соответствует некоторое целое число, идентифицирующее этот список. Узнать, занят ли данный номер каким-либо дисплейным списком, можно при помощи функции GLboolean gllsList ( GLuint list ); Зарезервировать range свободных подряд идущих номеров для идентификации дисплейных списков можно при помощи функции GLuint glGenLists ( GLsizei range ); Эта функция возвращает первый свободный номер из блока зарезервированных номеровданной командой. Для того чтобы выделить последовательность команд в Дисплейный список, служат процедуры glNewList и glEndList. Все команды, находящиеся между этими двумя, автоматически помещаются в соответствующий дисплейный список. void glNewList ( GLuint list, GLenum mode ); Здесь list - уникальное положительное число, служащее идентификатором списка, а параметр mode принимает одно из двух значений -
GL_COMPILE или GL_COMPILE_AND_EXECUTE. Первое из этих значений обеспечивает только запоминание (компиляцию) дисплейного списка, в то время как второе - запоминание и выполнение этого списка. Не все команды OpenGL могут быть записаны в Дисплейный список. Ниже приводится список команд, которые не могут быть помещены в дисплейный список. glDeleteLists () glFeedbackBuffer () glFinish () glGenLists () glGet* () gllsEnabled () gllsList () glPixelStore () glRenderMode () glSelectBuffer () Для того чтобы вызвать (выполнить) дисплейный список, служит процедура void glCallList ( GLuint list ); Возможно построение иерархических дисплейных списков, когда внутри определения одного списка вызываются другие списки. Для уничтожения дисплейных списков служит процедура void glDeleteLists ( GLuint list, GLsizei range ); Замечание. Дисплейные списки нельзя изменять. Список можно уничтожить или создать заново. В дисплейном списке команды запоминаются вместе со своими аргументами на момент передачи, так что в следующем примере последний оператор присваивания дисплейный список не изменяет. GLfloat color [] ={0.0, 0.0, 0.0 }; glNewList ( 1, GL_COMPILE ); glColor3fv ( color ) ; glEndList (); color [2] = 1.0; Работа c z-буфером Как уже было сказано, OpenGL поддерживает использование аппаратного z-буфера для удаления невидимых поверхностей. Для того чтобы использовать эту поддержку, необходимо разрешить использование теста глубины при помощи следующей команды:
А. В. Боресков. Графика трехмерной компьютерной игры glEnable ( GL_DEPTH_TEST ); Выключить эту проверку можно командой glDisable ( GL_DEPTH_TEST ); Существует возможность задания того, как OpenGL будет определять, что тест глубины выполнен. Для этого служит следующая функция: void glDepthFunc ( GLEnum func ); Значения параметра func задаются табл. 4.3. Таблица 4.3 Значение Условие выполнения текста GL.NEVER Никогда не выполнен GL_LESS Если поступающее значение глубины меньше значения, хранящегося в буфере глубины GL.EQUAL Если поступающее значение глубины совпадает со-значением, хранящимся в буфере глубины GLJLEQUAL Если поступающее значение глубины меньше или равно значению, хранящемуся в буфере глубины GL.GREATER Если поступающее значение глубины больше значения, хранящегося в буфере глубины GL_NOTEQUAL Если поступающее значение глубины не равно значению, хранящемуся в буфере глубины GL.GEQUAL Если поступающее значение глубины больше или равно значению, хранящемуся в буфере глубины GL.ALWAYS Всегда Задание моделей закрашивания Линия или заполненная грань может быть нарисована одним цветом (плоское закрашивание, GL_FLAT) или путем интерполяции цветов в вершинах (закрашивание Гуро, GL_SMOOTH). В последнем случае значения цвета для пикселов интерполируются между заданными значениями. Для задания режима закрашивания служит процедура void glShadeModel ( GLenum mode ); где параметр mode принимает одно из значений GL_FLAT илй GL_SMOOTH.
Освещение OpenGL использует модель освещенности, в которой свет приходит из нескольких источников, которые по отдельности могут быть включены или выключены. Кроме того, существует еще общее фоновое (ambient) освещение. Для правильного освещения объектов необходимо для каждой грани задать материал, обладающий определенными свойствами. Материал может испускать свой собственный свет, рассеивать падающий свет во всех направлениях (диффузное отражение) или отражать свет в определенных направлениях подобно зеркалу. Пользователь может определить до восьми источников света и определить их свойства, такие, как цвет, положение и направление. Для задания этих свойств служит процедура void glLight{if)[v](GLenum light, GLenum pname, TYPE param ); Эта процедура задает параметры для источника света light, принимающего значения GL_LIGHT0, GLJLIGHT1, .... GL_LIGHT7. Параметр pname определяет характеристику источника света, которая задается последним параметром. Возможные значения для pname приведены в табл. 4.4. Таблица 4.4 Значение Значение no умолчанию Комментарий GL_ AMBIENT (0,0,0,1) Фоновая RGBA-освещенность GL.DIFFUSE (1,1,1,1) Диффузная RGBA-освещенность GL_SPECULAR (1,1,1,1) Бликовая (Фоига) RGBA-освещенность GLJPOSITION (0,0,1,0) (х, у, z, w) позиция источника света GL_SPOT_ DIRECTION (0,0,-l) (х, у, z) направление для конических источников света GL_SPOT_ EXPONENT 0 Показатель степени в формуле Фонга GL_SPOT_ CUTOFF 180 Половина угла для конических источников света GL_CONSTANT_ ATTENUATION Отсутствует Параметр К<. (ур-ние 4.4) GL_LINEAR_ ATTENUATION Отсутствует Параметр К| (ур-ние 4.4) GL_QUADRATIC_ ATTENUATION Отсутствует Параметр К, (ур-ние 4.4)
Замечание. Значения по умолчанию GL_DIFFUSE и GL_SPECULAR в таблице относятся только к источнику света GL.LIGHT0, для остальных источников света значение по умолчанию есть (0,0,0, 1). Для использования источников света надо разрешить применение расчета освещенности командой glEnable ( GL_LIGHTING ); и разрешить (включить) соответствующий источник света при помощи команды glEnable, например glEnable ( GL_LIGHT0 ); Источник света можно рассматривать как имеющий вполне определенные координаты и светящий во всех направлениях или как направленный источник, находящийся в бесконечно удаленной точке и светящий в заданном направлении (х, у, z). Если параметр w в команде GL_POSITION равен нулю, то соответствующий источник света является направленным и светит в направлении (х, у, г). Если же w отлично от нуля, то это позиционный источник света, находящийся в точке с координатами (x/w, y/w, z/w). Заданием параметров GL_SPOT_CUTOFF и GL_SPOT_DIRECTION можно создавать источники света, которые будут иметь коническую направленность, как прожектор. По умолчанию значение параметра GL_SPOT_CUTOFF равно 180°, т. е. источник светит во всех направлениях в равной интенсивностью. Параметр GL_SPOT_CUTOFF определяет максимальный угол от направления источника, в котором распространяется свет от данного источника, он может принимать значение 180 (неконический источник) или от 0 до 90°. Вообще говоря, интенсивность источника убывает с расстоянием (параметры этого убывания задаются при помощи параметров GL_CONSTANT_ ATTENUATION, GL_LINEAR_ATTENUATION и GL_QUADRATIC_ATTE-NUATION), Только собственное свечение материала и глобальная фоновая освещенность не подвержены ослабеванию с расстоянием. Глобальное фоновое освещение можно задать при помощи команды void glLightModel{if}v ( GL_LIGHT_MODEL_AMBIENT, ambientColor ); Местонахождение наблюдателя оказывает влияние на блики на объектах. По умолчанию при расчетах освещенности считается, что наблюдатель находится в бесконечно удаленной точке, т. е. направление на наблюдателя постоянно для любой вершины. Можно включить более реалистическое ос
вещение, когда направление на наблюдателя будет вычисляться отдельно для каждой вершцны; для этого служит команда glLightModeli ( GL_LIGHT_MODEL_LOCAL_VIEWER, GLJTRUE ); Для задания освещения как лицевых, так и нелицевых граней (при этом для нелицевых граней вектор нормали переворачивается) служит следующая команда: glLightModeli ( GL_LIGHT_MODEL_TWO_SIDE, GL_TRUE ); При этом существует возможность отдельного задания свойств материала для каждой из сторон. Свойства материала, из которого сделан объект, задаются при помощи следующей процедуры: void glMaterial{if}[v] ( GLenum face, GLenum pname, TYPE param ); Параметр face указывает, для какой из сторон грани задается свойство, и принимает одно из следующих значений: GLJFRONT, GL_BACK, GL_FRONT_AND_BACK. Параметр pname указывает, какое именно свойство материала задается. Возможные значения представлены в табл. 4.5. Таблица 4.5 Значение Значение по умолчанию Комментарий GL.AMBIENT (0.2,6.2,0.2,1.0) Фоновый цвет материала GL.DIFFUSE (0.8,0.8,0.8,1.0) Диффузный цвет материала GL_AMBIENT_ AND_DIFFUSE Фоновый и диффузный цвета материала GL.SPECULAR (0,0,0,1) Цвет бликов GL.SHININESS 0 Коэффициент Фонга для бликов GL_EMISSION (0,0,0,1) Цвет свечения материала Итоговый цвет вычисляется по следующей формуле: Color ^E+I^ + TS, — т-х " " ' k'+k'd + k"^ Х(ЛЛ +тах{(/,п),0}/ЛА:,, +(тах{(М),0})" IstKs\, V 7 (4.4)
где Е - собственная светимость материала (GL.EMISSION); 1а - глобальная фоновая освещенность; Ка - фоновый цвет материала (GL_AMBIENT); S, - член, отвечающий за ослабление света в силу того, что источник имеет коническую направленность; он принимает следующие значения: • 1, если источник не конический; • 0, если источник конический и вершина лежит вне конуса освещенности, (max{(v,/), О}) , где v - единичный вектор от источника света к вершине; I - единичный вектор направления для источника света (GL_SPOT_DIRECTION); е - коэффициент GLSPOTEXPONENT; кс - коэффициент GL CONSTANT ATTENUATION; к, - коэффициент GL_LINEAR_ATTENUATION; d - расстояние до источника света; kq - коэффициент GL_QUADRATIC_ATTENUATION; 1а1 - фоновая освещенность от i-ro источника света; I - единичный вектор направления на источник света; п - единичный вектор нормали; 1а - диффузная освещенность от i-ro источника света; Kd - диффузный цвет (GL DIFFUSE); р - коэффициент Фонга (GL SHININESS); lst - бликовая освещенность от i-ro источника света; К, - цвет бликов (GL SPECULAR). После проведения всех вычислений, цветовые компоненты отсекаются по отрезку [0, 1].
Замечание. Расчет освещенности в OpenGL не учитывает затенения одних объектов другими. Ниже приводится пример использования освещенности в OpenGL. И void init () { glClearColor ( 0.0, 0.0, 0.0, 1.0 ); glEnable ( GL_DEPTH_TEST ); glShadeModel ( GL_FLAT ); // Set The Ambient Lighting For LightO glLightfv ( GL_LIGHTOL, GL_AMBIENT, lightAmb ) ; // Set The Diffuse Lighting For LightO glLightfv ( GL_LIGHT.0, GL_DIFFUSE, lightDif ); // Set The Position For LightO glLightfv ( GL_LIGHT0, GL_POSITION, lightPos ); glEnable ( GL_LIGHT0 ); // Enable Light 0 glEnable ( GL_LIGHTING ); // Enable Lighting void animate () { angle += 0.75f; angle2 += O.lf; glutPostRedisplay (); void display () { glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT ); glMatrixMode ( GL_MODELVIEW ); glLoadldentity (); gluLookAt ( 0, 0, 0, // eye 1, 1, 1, // center 0, 1, 0 ) ; // up glTranslatef ( 4, 4, 4 ) ; glRotatef ( angle2, 1, -1, o ) ; glTranslatef ( d - 1.5f, d - 1.5f, d - 1.5f ); glTranslatef ( 1.5f, 1.5f, 1.5f ); // move cube from the center
glRotatef ( angle, l.Of, l.Of, O.Of ); glTranslatef ( -1.5f, -1.5f, -1.5f ); // move cube into the center drawBox (, 1, 2, 1, 2, 1, 2 ) ; glutSwapBuffers (); } Полупрозрачность. Использование a-канала До сих пор мы не рассматривали a-канал (в RGBA-представлении цвета) и во всех примерах значение соответствующей компоненты всегда равнялось единице. Задавая значения, отличные от единицы, можно смешивать цвет выводимого пиксела с цветом пиксела, уже находящегося в соответствующем месте на экране, создавая тем самым эффект прозрачности. При этом наиболее естественно думать об этом, считая что RGB-компоненты задают цвет фрагмента, а a-значение - его непрозрачность. Так, если у стекла установить значение а, равное 0.2, то в результате вывода цвет получившегося фрагмента будет на 20 % состоять из собственного цвета стекла и на 80 % - из цвета фрагмента под ним. Для использования a-канала необходимо сначала разрешить режйм прозрачности и смешения цветов командой glEnable ( GL_BLEND ); В процессе смешения цветов цветовые компоненты выводимого фрагмента RSGSBS As смешиваются с цветовыми компонентами уже выведенного фрагмента RdGdBdAd по следующей формуле: (RsSr+RdDr,GsSt+GdDt,BsSb+BdDb,AsSa+AdDa),' (4.5) где (S,., Sg, Sb, Sa)и (Dr, Dg,Db,Da} - коэффициенты смешения. Для задания связи этих коэффициентов с a-значениями используется следующая функция: void glBlendFunc (GLenum sfactor, GLenum dfactor ); Здесь параметр sfactor задает то, как нужно вычислять коэффициенты Sg, Sb, S„), а параметр dfactor - коэффициенты (D,, Dg, Db, Da}. Возможные значения для этих параметров приведены в табл. 4.6.
Таблица 4.6 Значение Какие коэффициенты задействует Значение коэффициентов GL_ZERO S, D (0,0,0,0) GL_ONE S.D (1.1.1,1) GL_DST_COLOR S (Rd,Gd,Bd,Ad) GL_SRC_COLOR D (R„G„ Bs, Aj GL_ONE_MINUS_DST_COLOR S (l,l,l,l)-(/?d,Grf,^,Arf) GL_ONE_MINUS_SRC_COLOR D (1,1,1,1)-(K„G„B„ A,) GL_SRC_ALPHA S, D (AS,AS,AS,AS) GL_ONE_MINUS_SRC_ALPHA S,D (1,1,1,1)-(4,AS,AS,AI) GL_DST_ALPHA S.D (A?’ A/’ А<Л) GL_ONE_MINUS_DST_ALPHA S, D (l,l,l,l)-(Ad,Arf,Arf,Arf) GL_SRC_ALPHA_SATURATE S f = min(AJ, 1-Ad) Обратите внимание, что результат вывода полупрозрачных граней зависит от того, в каком порядке они выводились. Поэтому если в сцене присутствуют полупрозрачные грани, то необходимо сперва вывести все непрозрачные грани (в любом порядке), а затем отсортировать полупрозрачные грани и вывести их начиная с самой дальней (back-to-front). Вывод битовых изображений OpenGL поддерживает вывод битовых масок (изображений) - когда на 1 пиксел приходится 1 бит. Для вывода битовых масок служит процедура void glBitmap ( GLsizei width, GLsizei height, GLfloat xo, GLfloat yo, GLfloat xi, GLfloat yi, const GLubyte * bitmap );
Эта процедура выводит изображение, задаваемое параметром bitmap. Битовое изображение выводится начиная с текущей растровой позиции. Параметры width и height задают размер битового изображения в пикселах. Параметры хо и уо используются для задания положения нижнего левого угла выводимого изображения относительно текущей растровой позиции, параметры х< и yi представляют собой величины, прибавляемые к текущей растровой позиции после вывода изображения. Для задания текущей растровой позиции служит процедура void glRasterPos {234}{sifd}(v}( TYPE x, TYPE y, TYPE z >; Ввод-вывод цветных изображений OpenGL поддерживает также вывод полноцветных изображений (для каждого пиксела задаются либо все величины RGBA, либо только некоторые из них). Для копирования изображения из буфера кадра (фрейм-буфера) в обычную память служит процедура void glReadPixels ( GLint х, GLint у, GLsizei width, GLsizei height, GLenum format, GLenum type,GLvoid * pixels Здесь параметры (x, у) задают координаты левого нижнего угла, а параметры width и height - размеры копируемого изображения. Параметр format отражает, какие данные о пикселе заносятся в буфф; возможными значениями являются GL_RGB, GL_RGBA, GLARED, GL.GREEN. GLJBLUE, GL.ALPHA, GLJJJMINANCE, GL_LUMINANCE_ALPHA. GL_STENCILJNDEX, GLDEPTHJ2OMPONENT. Параметр type задает тип каждого из записываемых значений. Возможными значениями являются GL_UNSIGNED_BYTE, GL_BYTE, GL_BITMAP. GL_UNSIGNED_SHORT, GL_SHORT, GLJJNSIGNEDJNT, GLJNT и GL„FLOAT. Для вывода изображения в фрейм-буфер из оперативной памяти служит следующая процедура: void glDrawPixels ( GLsizei width, GLsizei height, Glenum format,GLenum type, const GLvoid * pixels ); При этом изображение выводится начиная с текущей растровой позиции.
Наложение текстуры Текстурирование позволяет наложить изображение на многоугольник и вывести этот многоугольник с наложенной на него текстурой, соответствующим образом преобразованной. OpenGL поддерживает одно- н двухмерные текстуры и различные способы наложения (применения) текстуры. Для использования текстур надо сначала разрешить одно- или двухмерное текстурирование при помощи следующих команд: glEnable ( GL_TEXTURE_1D ); ИЛИ glEnable ( GL_TEXTURE_2D ); Для задания двухмерной текстуры служит процедура void glTex!mage2D ( GLenum target, GLint level, GLint component,GLsizei width, GLsizei height, GLint border, GLenum format, GLenum type, const GLvoid * pixels ); Параметр target зарезервирован для будущего использования и в нынешней версии Он должен быть равен GL_TEXTURE_2D. Параметр level используется, если задается несколько разрешений данной текстуры; в случае задания только одного разрешения он должен быть равен нулю. Следующий параметр - components - это целое число от 1 до 4, показывающее, какие из RGBA-компонент выбраны для использования. Значение 1 выбирает компоненту R, значение 2 выбирает компоненты R и А, 3 соответствует R, G и В, а 4 соответствует компонентам RGBA. Параметры width и height задают размеры текстуры, border задает размер границы (бортика), обычно равный нулю. Как параметр width, так и параметр height должны иметь вид Т + 2Ь, где и - целое число, а b - значение параметра border. Максимальный размер текстуры зависит от реализации OpenGL, но он не менее 64x64. Смысл параметров format и type аналогичен их смыслу в процедурах glReadPixels и glDrawPixels. При текстурировании OpenGL поддерживает использование пирамидального фильтрования. Для использования этого необходимо иметь текстуры всех промежуточных размеров, являющихся степенями 2, вплоть до 1x1, н для каждого такого разрешения вызвать g!TexImage2D с соответ
ствующими параметрами level, width, height и image. Кроме того, необходимо задать способ фильтрования, который будет использоваться при выводе текстуры. Под фильтрованием здесь подразумевается способ, каким бу^ёт для каждого пиксела выбираться подходящий элемент текстуры (тексел). При текстурировании возможна ситуация, когда 1 пикселу будет соответствовать небольшой фрагмент тексела (увеличение) или же, наоборот, когда 1 пикселу соответствует целая группа текселов (уменьшение). Необходимо отдельно задать способ выбора соответствующего тексела как для увеличения, так и для уменьшения (сжатия) текстуры. Для этого используется процедура void glTexParameteri ( GL_TEXTURE_2D, GLenum pl, GLenum p2 ); где параметр pl показывает задается ли фильтр для сжатия или же для растяжения текстуры, принимая одно из значений GL_TEXTURE_MIN_ FLITER или GL_TEXTURE_MAG_FILTER. Параметр р2 задает способ фильтрования, возможные значения приведены в табл. 4.7. Таблица 4.7 Параметр pl Параметр р2 GL_TEXTURE_MAG_FILTER GL_NEAREST, GL_LINEAR GL_TEXTURE_MIN_FILTER GL.NEAREST, GL_LINEAR, GL_NEAREST_MIPMAP_NEAREST, GL_NEAREST_MIPMAP_LINEAR, GL_LINEAR_MIPMAP_NEAREST, GL_LINEAR_MIPMAP_LINEAR Значение GL_NEAREST соответствует выбору тексела с координатами, ближайшими к центру пиксела. Значению GL_LINEAR соответствует выбор взвешенной линейной комбинации из массива 2x2 тексела, лежащих ближе всего к рассматриваемому пикселу. При использовании пирамидального фильтрования помимо способа выбора тексела на одном слое текстуры появляется возможность либо выбрать один соответствующий слой, либо про-интерполировать результаты выбора между двумя соседними слоями. Для правильного применения текстуры следует для каждой вершины задать соответстйующие ему координаты текстуры при помощи процедуры void glTexCoord{1234}{sifd}[v] (TYPE coord, ... ); Этот вызов задает значение индексов текстуры для последующей команды glVertex *.
Если размер грани больше, чем размер текстуры, то для задания циклического повторения текстуры служат команды glTextParameteri ( GL_TEXTURE_2D, GL_TEXTURE_S_WRAP, GL_REPEAT ); glTextParameteri ( GL_TEXTURE_2D, GL_TEXTURE_T_WRAP, GL_REPEAT ); Каждая текстура в OpenGL идентифицируется целочисленным идентификатором. Перед использованием текстуры надо зарезервировать для нее идентификатор. Для резервирования набора подряд идущих идентификаторов служит функция void glGenTextures (GLsizei count, GLuint * textures ); где параметр count содержит число идентификаторов, которые надо зарезервировать. После того как идентификатор текстуры получен, надо сообщить OpenGL, что сейчас будет задаваться (или использоваться) текстура с данным идентификатором. Для этого служит функция void glBindTexture ( GLenum target, GLuint textures ); В этой функции параметр target принимает одно из следующих значений: GLTEXTURE1D или GL TEXTURE 2D. Параметр texture задает идентификатор текстуры. По возможности OpenGL пытается хранить текстуры во внутренней памяти ускорителя, поскольку это ускоряет доступ к ней. Однако это не всегда возможно (в первую очередь из-за ограниченного объема этой памяти). Следующая функция позволяет узнать, действительно ли текстура с заданным идентификатором хранится во внутренней памяти ускорителя. GLboolean glAreTexturesResident ( GLsizei n, GLuint * textures, GLboolean * flags ) ; Здесь первый параметр содержит количество текстур, о которых осуществляется запрос, массив textures содержит идентификаторы соответствующих текстур, а в массив flags для каждой из переданных текстур записывается признак того, содержится ли данная текстура в памяти ускорителя. Если все из переданных текстур содержатся в памяти ускорителя, то функция возвращает значение GLTRUE и в массив flags ничего не записывается. В противном случае возвращается значение GL_FALSE и в массив flags возвращается инфорация о наличие данных текстур в памяти ускорителя.
Общая схема работы с текстурами в OpenGL следующая: 1. Разрешить текстурирование при помощи glEnable (GL_TEXTURE_2D). 2. Для каждой текстуры необходимо получить уникальный идентификатор при помощи функции glGenTextures. 3. Выбрать текстуру при помощи glBindTexture. 4. Задать параметры текстуры и саму текстуру (glPixelStorei, glTexParame-teri, glTex!mage2D и т. д.). 5. Перед выводом грани, использующей текстуру, сделать ее текущей при помощи glBindTexture. По аналогии с тем, как координаты вершин и векторы нормали преобразуются моделирующей матрицей, координаты текстуры также подвергаются преобразованию при помощи матрицы текстурирования. По умолчанию она совпадает с единичной матрицей, но пользователь сам имеет возможность задать преобразования текстуры, например следующим образом: glMatrixMode ( GL_TEXTURE ); glRotatef ( ... ) ; glMatrixMode ( GL_MODELVIEW ); Ниже приводится пример вращающегося текстурированного куба без использования пирамидального фильтрования. Для загрузки текстуры из файла применяется библиотека аих. Можно легко заметить погрешности (артефакты) текстурирования. S unsigned textureld = -1; AUX_RGBImageRec * localTexture = NULL; void drawBox ( GLfloat xl, GLfloat x2, GLfloat yl, GLfloat y2, GLfloat zl, GLfloat z2 ) ( glBindTexture ( GL_TEXTURE_2D, textureld ); glBegin ( GL_POLYGON ); // front face glNormal3f ( 0.0, 0.0, 1.0 ) ; glTexCoord2f ( 0, 0 ); glVertex3f ( xl, yl, z2 ); glTexCoord2f ( 1, 0 ); glVertex3f ( x2, yl, z2 );
glTexCpord2f ( 1, 1 ); glVertex3f ( x2, y2, z2 ); glTexCoord2 f ( 0, 1 ) ; glVertex3f ( xl, y2, z2 ); glEnd (); glBegin ( GL_POLYGON glNormal3f ( 0.0, ) ; 0.0, -1. ,0 // ) ; back face glTexCoord2 f glVertex3 f ( ( 1, x2, 0 ); yl, zl ); glTexCoord2f glVertex3 f ( ( 0, xl, 0 ); yl, zl ) ; glTexCoord2 f glVertex3f ( ( 0, xl, 1 ); y2, zl ) ; glTexCoord2f glVertex3f glEnd (); ( ( 1, x2, 1 ); y2, zl ) ; glBegin ( GL_POLYGON glNormal3f ( -1.0, ) ; 0.0, 0. .0 // ) ; left face glTexCoord2f glVertex3 f ( ( 0, xl, 0 ); yl , zl ) ; glTexCoord2 f glVertex3f ( ( 0, xl. 1 ); yl, z2 ) ; glTexCoord2 f glVertex3f ( ( 1, xl. 1 ); y2, z2 ) ; glTexCoord2f glVertex3f glEnd (); ( ( 1, xl, 0 ); y2, zl ) ; glBegin ( GL_POLYGON glNormal3f ( 1.0, ) ; 0.0, 0.1 0 ) // t right face glTexCoord2f glVertex3f '( ( 0, x2, 1 ) ; yl, z2 );
glTexCoord2 f glVertex3 f ( 0, ( x2 , 0 ) ; yi. zl ) ; glTexCoord2f glVertex3 f ( 1, ( x2 , 0 ) ; У2, zl ) ; glTexCoord2 f glVertex3 f glEnd (); ( 1, ( x2, 1 > ; У2, z2 ) ; glBegin ( GL_POLYGON glNormal3f ( 0.0, ) ; 1.0, 0.0 ) // 1 ; top face glTexCoord2f glVertex3 f ( 0, ( xl. 1 ) ; У2, z2 ) , glTexCoord2f givertex3 f ( 1, ( x2 , 1 > ; У2, z2 ) , glTexCoord2f glVertex3 f ( 1, ( x2, 0 ) ; У2, zl ) , glTexCoord2 f glVertex3 f glEnd (); ( 0, ( xl, 0 ) ; У2, zl ) , glBegin ( GL_POLYGON ) ; // bottom face glNormal3f ( 0.0, -1.0, 0.0 ); glTexCoord2f givertex3f ( 1, ( x2, 1 ) ; yi, z2 glTexCoord2f ( 1, 0 ) ; glVertex3f ( xl, yi, z2 glTexCoord2f ( o, 0 ) ; glVertex3 f ( xl, yi, zl glTexCoord2f ( 1, 0 ) ; glVertex3f ( x2, yi, zl glEnd (); void init () { glClearColor ( 0.0, 0.0, 0.0, 1.0 ) ,-glEnable ( GL_DEPTH_TEST );
glEnable ( GL_TEXTURE_2D ); glPixelStorei ( GL_PACK_ALIGNMENT, 1 ); glPixelStorei ( GL_UNPACK_ALIGNMENT, 1 ); void animate () { angle += 0.75f; angle2 += O.lf; glutPostRedisplay (); void display () { glClear ( GL_COLOR_BUFFER_BIT ( GL_DEPTH_BUFFER_BIT ); glMatrixMbde ( GL_MODELVIEW ); glLoadldentity (); gluLookAt ( 0, 0, 0, 1, 1, 1, 0,1,0) ; // eye 7/ center // up glTranslatef glRotatef glTranslatef ( 4, 4, 4 ) ; ( angle2, 1, -1, 0 ); ( d - 1.5f, d - 1.5f, d - 1.5f ) glTranslatef // glRotatef glTranslatef // ( 1.5f, 1.5f, 1.5f ) ; move cube from the center ( angle, l.Of, l.Of, O.Of ) ; ( -1.5f, -1.5f, -1.5f ); move cube into the center drawBox ( 1, 2, 1, 2, 1, 2 ); glutSwapBuffers (); void reshape ( int w, int h ) { glViewport ( 0, 0, (GLsizei)w, (GLsizei)h ); glMatrixMode ( GL_PROJECTION ); glLoadldentity (); gluPerspective ( 60.0, (GLfloat)w/(GLfloat)h, 1.0, 60.0 );
glMatrixMode glLoadldentity gluLookAt } ( GL_MODELVIEW ); () ; ( 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 0.0 ) ; // eye II center void key ( unsigned char { if ( key ==27 || key == exit ( 0 ); key, •g' H int x, int у ) || key == 'Q* } quit requested } int main ( int argc, char ** argv ) { // initialise glut glutlnit ( barge, argv ); glutlnitDisplayMode ( GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH ); glutlnitWindowSize ( 400, 400 ); // create window int glwin = glutCreateWindow ( "OpenGL example 6’ ); initO ; // register handlers ' glutDisplayFunc ( display ); glutReshapeFunc ( reshape ); glutKeyboardFunc ( key ); glutldleFunc ( animate ); ' // load texture localTexture = auxDIBImageLoad ( "block.bmp" ); glGenTextures ( 1, &textureld ); glBindTexture ( GL_TEXTURE_2D, textureld ); glPixelStorei ( GL_UNPACK_ALIGNMENT, 1 ); // set 1-byte alignment II set texture to repeat mode glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT ); glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT ); glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR ); glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
glTex!mage2D ( GL_TEXTURE_2D, О, 3, localTexture -> sizeX, localTexture -> sizeY, 0, GL_RGB, GL_UNSIGNED_BYTE, localTexture -> data ); glutMainLoop (>; return 0; } Для устранения этих погрешностей текстурирования следует воспользоваться пирамидальным фильтрованием, что и делается в следующем примере. Для автоматического построения всех промежуточных уровней служит следующая функция: int gluBuild2DMipmaps ( GLenum target, GLint components, GLint width, GLlint height, GLenum format, GLenum type, const void * data ); Параметры имеют тот же смысл, который они имеют и для функции glTex!mage2D, но при этом строятся все уровни для использования пирамидального фильтрования. Кроме того, для этой функции размеры текстуры могут и не являться степенями двойки. га л. int main ( int argc, char ** argv ) { // initialise glut glutlnit ( barge, argv ); glutlnitDisplayMode ( GLUTJDOUBLE I GLOT_RGB | GLUT_DEPTH ); glutlnitWindpyfSize ( 400, 400 ) ; // create window int glwin = glutCreateWindow ( "OpenGL example 7" ); init(); // register handlers glutDisplayFunc ( display ); glutReshapeFunc ( reshape ); glutKeyboardFunc ( key ); glutldleFunc ( animate ) ,- // load texture localTexture = auxDIBImageLoad ( "block.bmp" );
А. В. Боресков. Графика трехмерной компьютерной игры ........................................................ glGenTextures ( 1, &textureld ); glBindTexture ( GL_TEXTURE_2D, textureld ); // set 1-byte alignment glPixelStorei ( GL_UNPACK_ALIGNMENT, 1 ); // set texture to repeat mode glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT ); glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT ); gluBuild2DMipmaps ( GL_TEXTURE_2D, GL_RGB, localTexture -> sizeX, localTexture -> sizeY, GL_RGB, GL_UNSIGNED_BYTE, localTexture -> data ); glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR ); glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR ); glutMainLoop (); return 0; } Обратите внимание, что для использования пирамидального фильтрования оказалось достаточным всего лишь задать этот режим при загрузке соответствующей текстуры. Все остальное делается автоматически. Артефакты текстурирования при этом практически полностью исчезли. Помимо явного задания текстурных координат в вершинах многоугольника существует способ их автоматического вычисления. В этом случае задавать текстурные координаты не нужно: они будут автоматически вычисляться для каждой вершины. Для задания способа автоматического вычисления координат служит функция void glTexGen [ifd] ( GLenum coord, GLenum pname, GLtype parm ); void glTexGen [ifd]v ( GLenum coord, GLenum pname, const GLtype * parms ); Первый параметр, coord, определяет координату текстуры, которая будет вычисляться автоматически и принимает одно из следующих значений: GL_S, GL_T, GL_R или GL_Q.
Следующий параметр, рпате, определяет используемый метод генерации текстурных координат и может принимать одно из следующих значений: GL_TEXTURE_GEN_MODE, GL_OBJECT_PLANE или GL_EYE_PLANE (последние два варианта могут использоваться только в векторном варианте команды). Последний параметр, parms, определяет формируемое значение и принимает одно из следующих значений: GL_OBJECT_LINEAR, GL_EYE_LINEAR или GL_SPHERE_MAP. Если текстурные координаты вычисляются при помощи функции GL_OBJECT_LINEAR, то соответствующая текстурная координата вычисляется по формуле g - Ptx+ р2у + p3z + PtW, (4.6) где g - вычисляемая текстурная координата; р,, р2, р2 и р4 - значения, заданные параметром parms-, (х, у, z,w)~ координаты вершины в системе координат объекта. Если координаты вычисляются с использованием GL_EYE_LINEAR, то применяется следующая формула: S = РЛ + р'2У' + РА + PtWe, где р{, р'г, р'3 и p'i - значения, определяемые следующей формулой: = М Pi Рз (4.7) (4-8) Здесь значения р,, р2, р3 и р4 - значения, переданные в parms, аМ-матрица видового преобразования (model-view matrix). Значения (хе, уе, ze, we) являются координатами верщины в системе координат камеры (наблюдателя). В случае когда рпате равен GL_SPHERE_MAP, a coord равно GL_S или GL_T, то сначала по следующей формуле вычисляется отраженный вектор: f = u-2(n,u)n, (4.9) где п - единичный вектор нармали после преобразования в систему координат камеры; и - единичный вектор в направленный из начала
координат к соответствующей вершине (в системе координат камеры). Тогда соответствующая текстурная координата вычисляется по формуле 5 = — + 0.5, г =—+0.5, m = 2J/; + /,2 + (/z+l)2. (4.10) т т ’ Последний способ вычислейия текстурных координат служит для получения эффекта, называемого environment mapping, когда создается иллюзия отражения в объекте окружающей среды. На самом деле заранее готовится текстура, содержащая вид на окружающую среду и накладывается на объект с использованием этого способа вычисления текстурных координат. Ниже приводится пример программы, демонстрирующей этот эффект. £! float angle = O.Of; unsigned textureld = -1; AUX_RGBImageRec * localTexture = NULL; void init () { glClearColor ( 0, 0, 0, 1 ); glEnable ( GL_DEPTH_TEST ); glEnable ( GL_TEXTURE_2D ); glEnable ( GL_CULL_FACE ); } void animate () { angle += l.Of; glutPostRedisplay (); } void display () { glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glBindTexture ( GL_TEXTURE_2D, textureld ); glTexEnvf ( GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_DECAL ); glTexGeni ( GL_S, GL_TEXTURE_GEN_MODE, GL_SPHERE_MAP ); glTexGeni ( GL_T, GL_TEXTURE_GEN_MODE, GL_SPHERE_MAP ) ; glPushMatrix ();
glEnable ( GL_TEXTURE_GEN_S ); glEnable ( GL_TEXTURE_GEN_T ); glRotatef ( angle, 1, 1, 1 ) ; glutSolidTorus(2.0,5.0,20,20); glDisable ( GL_TEXTURE_GEN_S ) ; glDisable ( GL_TEXTURE_GEN_T ); glPopMatrix (); glutSwapBuffers () ; void reshape ( int w, int h ) ( glViewport ( 0, 0, w, h ); glMatrixMode ( GL_PROJECTION ); glLoadldentity (); gluPerspective ( 60.0, (GLfloat) w/(GLfloat)h, l.Of, 60.Of ); glMatrixMode ( GL_MODELVIEW ); glLoadldentity (); gluLookAt ( 0, 0, 25, 0, 0, 0, 0, 1, 0 ); void key ( unsigned char key, int x, int у ) { if ( key ==27 || key == 'q' || key == 'Q' ) exit ( 0 ); // quit requested int main ( int argc, char ** argv ) { // initialise glut glutlnit ( &argc, argv ); glutlnitDisplayMode ( GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH ); glutlnitWindowSize ( 400, 400 ); // create window int glWin = glutCreateWindow ( "OpenGL example 8" ); ini t() ; // register handlers glutDisplayFunc ( display ); glutReshapeFunc ( reshape );
glutKeyboardFunc ( key ); glutldleFunc ( animate ); // load texture localTexture = auxDIBImageLoad ( "stars.bmp" ); glGenTextures ( 1, &textureld ); glBindTexture ( GL_TEXTURE_2D, textureld ); glPixelStorei ( GL_UNPACK_ALIGNMENT, 1 ); // set 1-byte alignment // set texture to repeat mode glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT ); glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT ); gluBuild2DMipmaps ( GL_TEXTURE_2D, GL_RGB, localTexture -> sizeX, localTexture -> sizeY, GL_RGB, GL_UNSIGNED_BYTE, localTexture -> data ); glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR ); glTexParameteri ( GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEARJMIPMAP_LINEAR 1; glutMainLoop (); return 0; } Для использования автоматического вычисления текстурных координат необходимо помимо задания параметров и способа их вычисления разрешить их вычисление. Это осуществляется посредством следующих вызовов: glEnable ( GL_TEXTURE_GEN_S ); glEnable ( GL_TEXTURE_GEN_T ); Запрещение автоматического вычисления осуществляется при помощи команд glDisable ( GL_TEXTURE_GEN_S ); glDisable ( GL_TEXTURE_GEN_T );
Управление наложением текстуры Помимо задания внутренних свойств самой текстуры следует задать также то, каким образом выводимая текстура накладывается на ранее выведенный фрагмент изображения. Для задания этого в OpenGL служат следующие функции: void glTexEnv[if](GLenum target, GLenum pname, GLtype param ); void glTexEnv[if]v(GLenum target, GLenum pname, GLtype * params ); Параметр target определяет конфигурацию текстуры и должен быть равен GL_TEXTURE_ENV. Параметр рпагпе может принимать значения GL_TEXTURE_ENV_MODE или GL_TEXTURE_ENV_COLOR (последнее значение может использоваться только в векторном варианте команды). Параметр param принимает одно из следующих значений: GL_MODULATE, GL DECAL или GL BLEND. Если в качестве pname было использовано значение GLTEXTUREENVCOLOR, то params содержит указатель на массив, содержащий RGBA-значение цвета. В табл. 4.8 приводится способ определения результирующего цвета фрагмента в зависимости от установленного режима и числа цветовых компонент текстуры. Таблица 4.8 Число компонент GL_MODULATE GL_DECAL GLJBLEND 1 Cv=LtCf \=Af He определено Cv=(l-L,)Cf+L,Cc \=Aj 2 C>=L,Ct \ = \Af He определено Cv=(l-L,)C/ + ACc Av=A,Af 3 cv-c,cf \=Af Q = c, \ = Aj He определено 4 Cv=C,Cf с„ = (1-а)с/ + ас, He определено
В этой таблице Cv и Д обозначают цвет и а-зиачение результирующего фрагмента, через С, и Д обозначают цвет, и а-значение накладываемой текстуры, а через Су и Д обозначают цвет и а-значение фрагмента, на который осуществляется наложение. Через Сс обозначено значение цвета, устанавливаемое при помощи векторного варианта команды. Через L, обозначена яркость текстуры (в случае, когда текстура имеет одну или две компоненты). Часто для получения желаемого эффекта одного наложения текстуры оказывается недостаточно. В этом случае можно Использовать многократный вывод грани с различными цветами, текстурами и способами Наложения. Работа с буфером трафарета Буфер трафарета (stencil buffer) является очень мощным средством, позволяющим работать на уровне отдельных пикселов. Он может использоваться, например, для отсечения отраженного изображения по границе зеркала. Для использования буфера трафарета сначала следует разрешить Проведение теста трафарета при помощи команды glEnable (GL_STENCIL_TEST ); Для запрещения использования теста трафарета служит команда glDisable ( GL_STENCIL_TEST ); Задать закон прохождения теста трафарета можно при помощи следующей функции: void glStencilFunc ( GLenum func, GLint ref, GLuint mask ); Параметр func определяет, в каком случае тест трафарета считается выполненным. Возможные значения для этого параметра приводятся в табл. 4.9. Таблица 4.9 Значение Условие выполнения GL_NEVER Никогда 1 GL_LESS Если (ref & mask)<(stencil & mask) GL_EQUAL Если (ref & mask)==(stencil & mask)
GLJJEQUAL Если (ref & mask)<=(stencil & mask) GL_CREATER Если (ref & mask)>(stencil & mask) GL_NOTEQUAL Если (ref & mask)!=(stencil & mask) GLJ3EQUAL Если (ref & mask)>=(stencil & mask) GL_ALWAYS Всегда Для задания действия над буфером трафарета в зависимости от прохождения теста трафарета и теста глубины служит функция void glStencilOp ( GLenum fail, GLenum zfail, GLenum zpass ); Первый параметр задает действие, которое будет выполнено над значением трафарета для текущего пиксела в случае, если тест трафарета не выполнен. Параметр zfail задает действие, которое будет выполнено в случае, когда тест трафарета выполнен, а тест глубины - нет. Третий параметр задает действие, когда выполнены и тест трафарета и тест глубины. Возможные значения для этих параметров приводятся в табл. 4.10. Таблица 4.10 Значение Действие GL_KEEP Текущее значение ие изменяется GL_ZERO Текущее значение обнуляется GL_REPLACE Текущее значение заменяется значением параметра ref GLJNGR Текущее значение увеличивается иа единицу GL_DECR Текущее значение уменьшается на единицу GLJNVERT Осуществляется побитовое инвертирование текущего значения На самом деле OpenGL перед выводом каждого пиксела осуществляет целый ряд проверок, позволяющий отбрасывать отдельные пикселы. Ниже приводится диаграмма, показывающая порядок этих проверок. Рис. 4.7
Сохранение параметров Поскольку при работе с OpenGL часто возникает необходимость в изменении состояния OpenGL (параметры вывода, типы осуществляемых проверок и т. п.), то весьма удобной является возможность запоминать текущее состояние определенных параметров в стеке (наподобие стека матриц) и восстанавливать состояние потом. Для запоминания ряда параметров служит функция void glPushAttrib ( GLbitfield mask ); Параметр mask задает типы параметров текущего состояния, которые должны быть сохранены этой командой. Наиболее распространенными значениями являются GL_COLOR_BUFFER_BIT, GL_ENABLE_BIT, GL_DEPTHBUFFER_BIT, GL_STENQL_BUFFER_BIT и GL_TEXTURE_BIT. Полный список возможных битовых значений (вместе со списком сохраняемых ими параметров) можно найти в описании OpenGL. Функция void glPopAttrib (); восстанавливает состояние сохраненных параметров, снимая их со стека.
Глава 5. ОБЪЕКТНАЯ МОДЕЛЬ. ОСНОВНЫЕ КЛАССЫ Поскольку в рассматриваемом нами проекте мы собираемся активно использовать принципы и приемы объектно-ориентированного программирования (ООП), то для облегчения дальнейшей работы удобно будет сразу определить применяемую объектную модель и основные классы. Довольно часто в ООП смешиваются два близких понятия - абстрактные типы данных (АТД) и объекты. Если мы хотим построить удобную и расширяемую в дальнейшем объектную модель, то эти два понятия необходимо четко различать. Под АТД обычно подразумевают объединение данных с методами для их обработки. Хотя при этим может встречаться наследование АТД, но их методы при этом не переопределяются, т. е. фактически АТД - это просто "объектная обертка" вокруг структуры. Простейшими примерами АТД являются введенные ранее классы Vector2D, Vector3D, Plane и т. п. Полиморфизм в случае АТД практически отсутствует и вызываемые методы одно-шачно определяются на этапе компиляции. С другой стороны, часто встречается необходимость в полиморфизме, наследовании и переопределении методов объектов непосредственно па стадии выполнения программы. Например, в конструкции bject -> draw ( view, camera ); какая конкретно функция будет вызвана у объекта object, определяется уже на этапе выполнения и зависит от реального (а не декларированного) типа переменной object. Существует возможность для пользователя уже после <а вершения работы над библиотекой классов (например, для проверок на с 1 олкновение или рендеринга сложных объектов) добавлять новые классы, унаследованные от базовых, переопределив некоторые их методы. Тогда написанный пользователем класс будет корректно работать с библиотекой, несмотря на то что библиотека была написана и откомпилирована задолго до написания соответствующего класса. Можно даже добавлять новые к 1ассы прямо в ходе выполнения программы (подобная функциональность будет рассмотрена во второй части работы). ДОМОГГИИФИ 151
Важно отличать полиморфизм, предоставляемый через механизм наследования (т. е. фактически следование объявленному протоколу) от использования макросов или шаблонов (templates) языка C++. В последнем случае мы имеем дело фактически с макропрограммированием, причем довольно слабым. Все опять определяется на этапе компиляции и отличается от использования конструкции #define только встроенной проверкой типов. Кроме того, степень поддержки шаблонов довольно сильно меняется от компилятора к компилятору. В отличие от механизма наследования использование шаблонов и макросов работает, как уже было сказано, только на этапе компиляции, в то время как механизм наследования обеспечивает возможность прямо в процессе выполнения загружать реализации новых объектов (или измененные версии уже существующих объектов) и корректно работать с ними. Именно исходя из этих соображений в рассматриваемом проекте почти не используются шаблоны и библиотека STL, целиком построенная на них. Также не используется пресловутая модель СОМ, которая является слишком сложной и неудобной для практической работы (за исключением разве что пользователей языка Visual Basic, в основном для которых она и была создана). В основе предлагаемой объектной модели лежит объектная модель из операционной системы NextStep (сейчас используемая в Max OS X), адаптированная к языку C++ (насколько это возможно в силу ограниченности языка C++). Класс Object является корневым классом для всех используемых далее объектов (в отличие от АТД) (в конце этой главы приводится диаграмма введенных в него классов). Данный класс поддерживает подсчет количества ссылок на него (reference counting), элементы RTTI, возможности сравнения объектов, использования их в качестве ключей в ассоциативных массивах, имя и битовые флаги. Ниже приводится описание этого класса. Е1 class Object { protected: char * name; int refCount; long flags; Object * owner; int lockcount; MetaClass * metaClass;
public: Obj ect () ; Object ( const char * aName ); virtual -Object (); virtual const char * getClassName () const { return metaClass != NULL ? metaClass -> getClassName () : ""; virtual bool isOk () const // returns non-zero if objects is ok { return true; virtual bool isNull () const { return false; virtual int init () // postconstructor initialization { return 1; virtual long hash () const // return hash for object { return 01; bool isKindOfClass ( const MetaClass& theClass ) const return metaClass != NULL ? metaClass -> isKindOfClass ( theClass ) : false; bool isInstanceOfClass ( const MetaClass& theClass ) const return metaClass != NULL ? metaClass -> isInstanceOfClass (theClass) : false; virtual int compare ( const Object * obj ) const;
II returns non-zero if this object is // equal to the object obj virtual bool isEqual ( const Object * obj ) const { return compare ( obj ) == 0; } virtual void lock () { if ( lockCount++ < 1 ) doLock (); } virtual void unlock () { if ( --lockCount < 1 ) doUnlock (); } virtual void doLock () {} virtual void doUnlock () {} int release (); Object * autorelease (); Object * retain () { refCount++; return this; } const char * getName () const { return name; } void setName ( const char * newName ); void setFlag ( int value ) { flags |= value; }
void clearFlag ( int value ) { flags &= -value; } bool testFlag ( int value ) const { return (flags & value) == value; } Object * getOwner () const { return owner; } void setowner ( Object * newOwner ); static MetaClass classinstance; } ; Объекты класса MetaClass служат для работы с так называемой метаинформацией объекта. Они содержат имя класса и ссылку на метаинформацию родительского класса. Подобные объекты позволяют получать более точную информацию об объектах на этапе выполнения программы (поскольку сам язык C++ подобную информацию предоставить просто не в состоянии). В качестве подобной метаинформации также может выступать версия и описание класса, методы для создания экземпляров класса (что позволяет построить так называемый виртуальный конструктор, когда тип создаваемого объекта определяется в ходе выполнения), списки переменных и методов (вместе с информацией об их типах). Последнее позволяет легко добавлять сохраняемость (persistence) и поддержку скриптовых языков (Python, Lua и др.). Сам объект хранит ссылку на свою метаинформацию, которая разделяется всеми объектами данного класса. Е class MetaClass < protected: const char * className; // name of the class MetaClass * superclass; // link to parent metaclass public: MetaClass ( const char * theClassName, MetaClass * super )
{ className = theClassName; superclass = super; } const char * getClassName () const { return className; } MetaClass * getSuperClass () const { return superclass; } bool isKindOfClass ( const MetaClass& theClass ) const; bool isInstanceOfClass ( const MetaClass& theClass ) const; }; Класс Object, как и все классы, производные от него, использует механизм учета ссылок на себя {reference counting). Каждый объект, который хочет хранить внутри себя ссылку на другой объект, должен вызвать у этого объекта метод retain, который увеличивает значение счетчика ссылок ref-Count на единицу. Когда объект становится ему больше не нужен, он должен вызвать метод release, уменьшающий значение счетчика на единицу. При этом если этот объект больше никому не нужен (refCount==0), то он будет автоматически удален. Подобный подход позволяет эффективно разделять объекты внутри системы, не беспокоясь, что объект будет уничтожен, пока он кем-либо используется, или что он не будет уничтожен никогда. Этот подход активно применяется в вводимых далее контейнерных классах Array, Set и Dictionary. Основным его преимуществом является то, что пользователю не нужно все время помнить, работает ли с этим объектом кто-то еще или же его можно удалить. Вместо этого, когда в конкретном месте объект становится больше не нужен, у него просто вызывается метод release. Удаление объекта произойдет лишь в том случае, если этот объек! никем больше не используется. Иногда бывает удобно не удалять объект сразу, а лишь пометить его как больше не нужный и передать дальше. Если там он понадобится, у пего будет вызван метод retain и он будет сохранен. Но при этом непосредственно вызывать метод release и после этого передавать указатель на данный объект нельзя - в результате этого объект может быть просто удален к моменту его передачи и указатель будет указывать на уже разрушенный объект
Именно для этого и служит метод autorelease. Он не удаляш объект, а лини, помещает ссылку на него в объект класса AutoreleasePool, где хранятся ссылки на объекты, которые помечены для удаления. Время от времени (например, в начале очередного цикла обработки сообщения) всем объектам из этого списка посылается сообщение release. Метод init служит для осуществления инициализации объекта, которая не может быть выполнена в конструкторе класса (например, потому, что обращается к виртуальным методам, которые определяются в унаследованных классах и недоступны в моменты вызова конструктора базового класса). Метод isOk служит для проверки состояния объекта и возвращает значение true, если объект пригоден для использования. Метод compare служит для сравнения двух объектов и переопределяется в подклассах. Метод hash ставит в соответствие объекту некоторое число, которое может в дальнейшем использоваться в хеш-таблицах и для быстрого сравнения объектов. Метод getClassName возвращает имя класса данного объекта. Метод IsKindOfClass служит для определения того, действительно ли объект унаследован от данного класса. Метод islnstanceOfClass определяет, действительно ли объект является объектом данного класса. В ряде случаев возникает необходимость заблокировать объект-до окончания каких-либо действий над ним. При этом желательно, чтобы такое блокирование поддерживало вложенные вызовы. Поддержка вложенных вызовов осуществляется при помощи переменной lockCount, которая хранит в себе число активных на данный момент заблокирований объекта. Когда она равна нулю, то объект разблокирован. Определяемые в наследуемых классах методы doLock и doLhdock служат для осуществления действительно блокирования и разблокирования объекта. Вынос этих операций в отдельные методы связан с тем, что вызовы методов lock и unlock могут быть вложенными и поэтому методы doLock и doUnlock вызываются, только когда действительно нужно заблокировать или разблокировать объект. Объект может иметь имя, работа с которым осуществляется при помощи методов getNa/ne и selName. Также объекты, унаследованные от класса Objecl. поддерживают набор битовых флагов, которые могут использоваться дочерними классами по своему усмотрению для обозначения состояния объекта, его возможностей и т. п. Для работы с этими флагами служат методы setFlag (установить
соответствующий флаг), clearFlag (очистить флаг) и testFlag (проверить, установлен ли флаг). Также объект может иметь ссылку на другой объект, от которого он зависит (или которому принадлежит) (parent) и использовать методы get-Parent и setParent для работы с ним. Класс Object служит основой для построения других классов. Одним из таких классов является класс String. Представление строки в виде объекта несет в себе ряд преимуществ, в частности избавляет программиста от необходимости вручную управлять распределением памяти для строки и позволяет использовать строки в качестве ключей в ассоциативных массивах. Также в этот класс вводятся методы для сравнения строк и ряд других полезных функций, заметно облегчающих работу с этим классом. Ниже приводится описание этого класса. та class String : public Object ' { private: char * contents; intlength; // actual length of string intmaxLength; // size of preallocated buffer public: String (); String ( const char * value ); String ( const Strings str ); String ( int value ); String ( float value ); -String () { delete contents; } virtual bool isOk () const 7/ returns non-zero if objects is ok { return contents != NULL && maxLength > 0 && length < maxLength; } virtual bool isNull () const { return contents == NULL; }
Virtual long hash () const // return hash for object return crc32 ( contents, length ); // returns non-zero if this object is // equal to the object obj virtual int compare ( const Object * obj ) const; intcaselnsensitiveCompare ( const Strings str ) const { return stricmp ( contents, str.contents ); } operator const char * () const { return contents; } const char * c_str () const { return contents; } Strings operator = ( const Strings str ); Strings operator = ( char ch ); Strings operator += ( const Strings str ) { append ( str.contents ); return *this; } Strings operator += ( const char * str ) { append ( str ); return *this; } Strings operator += ( char ch ); String operator + ( const Strings str ) const { String s ( *this );
s.append ( str.contents ); return s; } String operator + (const char * str ) const { String s ( *this ); s.append ( str ); return s; } String operator + (char ch ) const { String s ( *this ); return s += ch; } bool operator == ( const Strings str ) const { return strcmp ( contents, str.contents ) == 0; } bool operator != ( const Strings str ) const { return strcmp (contents, str.contents ) != 0; } bool operator < ( const Strings str ) const { return strcmp ( contents, str.contents ) < 0; } bool operator <= ( const strings str ) const { return strcmp ( contents, str.contents ) <= 0; } bool operator > ( const Strings str ) const { return strcmp ( contents, str.contents ) > 0; }
t bool operator >= ( const String& str ) const ’ { return strcmp ( contents, str.contents ) >= 0; } bool operator == ( const char * str ) const { return strcmp ( contents, str ) == 0; } bool operator’ != ( const char * str ) const { return strcmp ( contents, str ) != 0; } bool operator < ( const char * str ) const { return strcmp ( contents, str ) < 0; } bool operator <= ( const char * str ) const { return strcmp ( contents, str ) <= 0; } bool operator > ( const char * str ) const { return strcmp ( contents, str ) > 0; } bool operator >= ( const char * str ) const { return strcmp ( contents, str ) >= 0; } char& operator [] ( int index ) const { return contents [index]; } intgetLength 0 const { return length; }
bool isEmpty () const { return length < 1; } Strings toUpper () { strupr ( contents ); return *this; } Strings toLower () { strlwr ( contents ); return *this; } int float double tolnt toFloat toDouble () const; () const; 0 const; String substr ( int from, int count = toTheEnd const; Strings setLength ( int ); Strings cut ( int from, int count = toTheEnd Strings trim 0 ; Strings Strings removeLeadingSpaces removeTrailingSpaces 0 ; () ; Array * int find split ( const Strings ( const Strings sep ) const; pattern, int start = 0, int find int options = ( char ch, int 0 ) const; start = 0, int options = 0 Strings const; replace ( int start, int len, const Strings str Strings Strings insert ( int pos, insert ( int pos, char ch ); const Strings str ); static String getExtension static void setExtension static String getPath static String etFileName ( const Strings fileName ); ( Strings fileName, const Strings ext ); ( const Strings fullName ) ; ( const Strings fullName );
Static String buildFileName ( const String& path, const String& name ); static String printf ( const char * format, ... ); static void parseString ( const String& str, String& cmd, String& args ); enum { toTheEnd = 0x80000000 // operation up to the end of string }; private: void realloc ( int newLen ); void append ( const char * ); static MetaClass classinstance; }; Конструктор этого класса позволяет строить экземпляры класса как по другой строке (в том числе и по const char *), так и по вещественному или целому числу. Метод caselnsensitiveCompare осуществляет сравнение строк не различая больших и маленьких букв. Оператор const char * позволяет использовать объекты класса String в тех случаях, когда требуется указатель на строку в стиле языка С. Операторы +=, +, ==, !=, <, <=, >, >= осуществляют стандартные операции над строками - конкатенацию и сравнение. Оператор [ ] служит для доступа к отдельным символам строки. Методы toLower и toUpper служат для перевода строки в большие и маленькие буквы соответственно. Методы tolnt, toFloat и toDouble позволяют переводить в строку числа различных типов. Метод substr позволяет получить подстроку заданной длины начиная с заданной позиции, при этом использование отрицательного значения позиции позволяет получать подстроки начиная с конца строки, так s.substr (-2) возвращает строку, состоящую из последних двух символов исходной строки. Метод cut позволяет вырезать группу символов из заданной строки. Метод find служит для поиска заданного символа или подстроки и возвращает индекс первого вхождения в строке. При этом все объекты класса String автоматически осуществляют управление выделением памяти для всех операций, что освобождает программиста от необходимости все время думать об этом.
Для повышения эффективности выделение памяти объектами этого класса осуществляется блоками, кратными некоторому фиксированному значению (32 байта). При этом переменная length содержит текущую длину строки в символах (без учета '\0'), а переменная maxLength - размер выделенного буфера в байтах. Выделение блока большего размера происходит только в том случае, если maxLength байт недостаточно. Еще одним примером класса в нашей модели является класс Log, реализующий так называемый лог, в который в процессе работы программы можно записывать различную информацию. При этом запись информации осуществляется как в файл с заданным именем, так и в debug log системы Windows, что позволяет просматривать сообщения прямо из среды отладки. Кроме того, после каждой записи в файл происходит сброс записи на диск, что сохраняет записи в случае падения программы (или Windows). Ниже приводится его описание. та class LogManipulator { public: LogManipulator () {} } ; class Log : public Object { private: String buf; String fileName; public: Log ( const Strings theFileName = "“ ); Logs operator « ( const Strings str ) { buf += str; return *this; } Logs operator « ( const char * str ) { buf += str; return *this;. }
Log& operator << ( char ch ) { buf -t= ch; return *this; } Log& operator << ( bool v ) { if ( v ) buf += "true"; else buf += "false"; return *this; } Log& operator « ( int v ); Log& operator << ( float v ); Log& operator << ( const LogManipulator& man ); static MetaClass classinstance; protected: void flush (); // flushes buf to log, called on logEndl; }; Для добавления записей в лог используются перегруженный оператор <<, позволяющий легко выводить значения разных типов. При этом выводимые значения вначале записываются во внутренний буфер. Вывод значения фиктивного класса LogManipulator служит для записи содержимого буфера в файл и debug log Windows. Для удобства сразу определяется указатель на стандартный лог - sysLog, а также объект класса LogManipulator - logEndl. Таким образом, для вывода информации в лог можно использовать конструкции вида (*sysLog) << "Loading map " << mapName << logEndl; Для хранения наборов объектов также удобно использовать объекты специальных классов, а не встроенные массивы языка С. Такие объекты обычно называются контейнерами, и они предоставляют программисту ряд преимуществ по сравнению со стандартными массивами.
К этим преимуществам относятся в том числе автоматическое управление размером контейнера, сравнение, поиск и сортировка элементов контейнера. Очень удобны в работе так называемые ассоциативные контейнеры, позволяющие получать доступ к элементам по произвольному ключу (например, строке). Простейшим контейнерным классом в нашей библиотеке является класс Array. Он представляет собой обычный массив для хранения объектов, но с возможностью автоматического изменения своего размера при необходимости. Ниже приводится его описание. S typedef int (*ObjectComparator)(Object *, Object *, void *); class Array : public Object { protected: int maxiterns; // current capacity of array int numitems; // current # of items in array int delta; // by how much items grow array Object ** items; // aray containing items // (or pointers to them) public: Array ( const char * theName = ”", int theDelta =50 ); Array ( const Array& array ); -Array (); virtual virtual virtual bool long int isOk () hash () compare ( const; const; const Object * obj ) const bool operator == ( const Array& array ) const; bool operator != ( const Array& array ) const { return ! operator == ( array ); } Object * getObjectWithName ( const String& theName ); void removeAll (); // delete all items int removeAtlndex ( int pos ); // delete specified item int removeObjectWithName ( const String& theName );
Object * insertNoRetain ( Object * item ); // insert object withouit retaining it Object * insert ( Object * item ); // append item, retaining it Object * atPut ( int pos, Object * item ); // put item at a given position Object * atlnsert ( int pos, Object * item ); Object * at ( int pos ) const // return item at position { return pos >= 0 && pos < numitems ? items [pos] : NULL; } int realloc ( int newSize ); // set new capacity of container int indexOfObject ( Object * ) const; // object identical compare int indexOfObjectldenticalTo ( Object * ) const; // index of object with the same address void sort ( Objectcomparator func, void * arg ); bool isEmpty () const { return numitems < 1; } int getCount () const { return numlterns; } int getNumlterns () const { return numitems; } int getDelta () const { return delta; }
class Iterator { private: const Array * array; int index; public: Iterator ( const Array * theArray ) { array = theArray; index = 0; } int end () const { return index >= array -> getCount (); } Object * value () const { return array -> at ( index ); } void operator ++ () { index++; } Iterator getlterator () const { return Iterator ( this ); static MetaClass classinstance; Метод insert служит для вставки элемента в конец массива. При этом у вставляемого объекта автоматически вызывается метод retain, гарантирующий, что объект не будет уничтожен в обход содержащего его контейнерного класса. Существует также возможность поместить в массив элемент, не вызывая при этом метод retain, например в случае, если вставляемый объект был создан при помощи new специально для вставки в массив и больше не нужен. Для этого служит метод insertNoRetain. Метод atlnsert позволяет вставить объект на заданное место, при этом некоторые из содержащихся внутри массива элементов могут сдвинуться.
Методы removeAll, removeAtlndex и removeObjectWithName позволяю! удалить все элементы массива, объект с заданным индексом и объект с заданным именем. При этом каждому удаляемому объекту посылается сообщение release. Метод at позволяет получить доступ к элементу массива по его индексу. Методы getCount и getNuinlteins возвращают количество элементов в массиве. Метод isEmpty возвращает true, если в массиве содержится хотя бы один объект. Метод sort позволяет отсортировать элементы массива с использованием переданной функции сравнения объектов. Для обхода контейнерных классов удобно использовать паттерн "Итератор" [12]. Внутри каждого из основных контейнерных классов определяется специальный класс Iterator, реализующий метод обхода элементов контейнера. У каждого контейнерного класса должен быть свой метод getltera-tor, возвращающий итератор на данный контейнер. У каждого итератора определяются следующие базовые методы: end(), value() и ++. Метод end возвращает признак того, находится ли итератор в конце контейнера. Метод value возвращает указатель на очередной объект, на который показывает итератор. Метод ++ служит для перемещения итератора к следующему объекту. Еще одним полезным классом служит класс Set, являющийся непосредственным аналогом обыкновенного множества. В нем каждый объект может храниться только в одном экземпляре (в отличие от класса Array) и существует возможность быстрой проверки наличия объекта в контейнере (без полного перебора всех элементов). Для реализации быстрого поиска объектов внутри контейнера используется хеш-таблица, ключ в которую выбирается исходя из значения, возвращаемого методом hash. Подобный подход позволяет очень быстро находить нужный объект. Н class Set : public Object { struct Hashitem { Hashltem * next; // pointer to next item in chain Object * data; // object being stored long hashvalue; };
private: Hashitem ** hashTable; int hashsize; // table contains hashsize elements int numitems; static MemoryPool pool; // pool of Hashitems public: class Iterator II set iterator { private: const Set * set; // set we're iterating on int hashindex; // index into hash table Hashitem * item; // current hash item public: Iterator ( const Set * ); Object * value () const { return item -> data; } bool end 0 const { return hashindex >= set -> hashsize; } void operator ++ (); }; friend class Iterator; Set (); Set ( const Set& theSet ); Set ( const char * theName, int theHashSize = 1000 ) ; -Set (); virtual bool isOk () const { return hashTable != NULL && hashsize > 0; } virtual int compare ( const Object * obj ) const;
virtual long hash () const { return numitems; } bool operator == ( const Set& set ) const { return compare ( &set ) == 0; } bool operator != ( const Set& set ) const { return ! operator == ( set ); } bool has ( Object * item ) const; // return non-negative if set contains item Object * insert ( Object * item ); // insert item at appropriate position void remove ( Object * item ); // delete item at specified position void removeAll (); // delete all items bool isEmpty () const { return numitems < 1; } int getNumltems () const { return numitems; } Iterator getlterator () const { return Iterator ( this ); } Array * getltems () const; static MetaClass classinstance; }; Метод insert, как и в случае массива, добавляет новый элемент к множеству.
Методы remove и removeAll служат для удаления заданного элемента или всех элементов соответственно. Метод has позволяет быстро проверить принадлежность объекта к множеству. Методы isEmpty и getNumlteins работают аналогачно методам класса Array. Для обхода всех элементов множества также используется паттерн "Итератор". Метод getltems возвращает указатель на массив, содержащий все элементы данного множества. Для быстрого выделения и освобождения элементов хеш-таблицы вместо обычных new и delete используется объект класса MemoryPool, оптимизированный для выделения большого числа маленьких блоков одинакового размера. Последним примером контейнерного класса является ассоциативный массив, представленный классом Dictionary. Он позволяет хранить объекты и получать доступ к ним по произвольному ключу (например, объекту класса String). Для организации этого класса также используется хеш-таблица. Н class Dictionary : public Object { struct Hashitem { Hashitem * next; // pointer to next item in chain Object * key; // key object Object * data; // object being stored long hashvalue; // hash value if the key }; private: Hashitem ** hashTable; // hash table int hashsize; // table contains hashsize elements int numitems; // # of items currently in the dictionary static MemoryPool pool; // pool of Hashitems public: class Iterator // dictionary iterator { private: const Dictionary * diet; // dicitonary we're iterating on
int Hashitem public: Iterator hashindex; * item; // index into hash table // current hash item const Dictionary * ); Object * key () const ( return item -> key; } Object * value () const { return item -> data; } bool end () const { return hashindex >= diet -> hashsize; } void operator ++ (); }; friend class Iterator; Dictionary (); Dictionary ( const Dictionary& theDict ); Dictionary ( const char * theName, int theHashSize = 1000 ) ; -Dictionary (); virtual bool isOk () const { return hashTable != NULL && hashsize > 0; } virtual int compare ( const Object * obj ) const; virtual long hash () const { return numitems; } bool operator == ( const Dictionary^ diet ) const { return compare ( &dict ) == 0; }
bool operator != ( const Dictionary& diet ) const ( return ! operator - = ( diet ) ; } Object * itemForKey ( const Object& key ) const // return item for given key on NULL ( return itemForKey ( &key ); } void removeobject ( const Object& key ) { removeobject ( &key ); } Object * itemForKey ( const Object * key ) const; // return item for given key on NULL Object * insert ( Object * key, Object * item ); // insert item at appropriate position bool removeobject ( const Object * key ); // delete item for given key void removeAll () ; // delete all items bool isEmpty () const { return numitems < 1; } int getNumltems () const ( return numitems; } Iterator getlterator () const ( return Iterator ( this ); } Array * getKeys () Array * getltems () static MetaClass } ; const; // return array of all // keys in the dictionary const; // return array of all // items in the dictionary classinstance;
Метод insert в данном классе получает на вход два параметра -собственно сам вставляемый объект и ключ, который будет использован для доступа к этому объекту. И ключу и вставляемому объекту посылается сообщение retain. Метод itemForKey возвращает указатель на объект, связанный с данным ключом, или NULL, если с этим ключом не связан ни один объект в данном контейнере. Метод removeObject удаляет объект по заданному ключу. При этом и ключу и удаляемому объекту посылается сообщение release. Методы getKeys и getltems возвращают массивы всех ключей и всех элементов соответственно. Обратите внимание, что объекты любого класса, поддерживающего методы hash и isEqual, могут выступать в качестве ключей в ассоциативном массиве (в том числе и одновременно). Еще одним удобным классом является класс ConfigParser, служащий для чтения и разбора конфигурационного файла, в котором могут задаваться параметры системы (используемое разрешение экрана, используемые клавиши, параметры игрока и т. п.). Пример такого файла приводится ниже. # # Sample config file # video { width 640 height 480 fullscreen false } player ( fov 6 0 forward_vel 0.5 backward_vel 0.5 strafe_vel 0.3 size (0.2, 0.8, 0.2) } detail { texture "../Textures/detail2.tga" distance 1
scale 20 } lightmaps on map "Data/f.sc" Как видно из приведенного примера, в конфигурационном файле допускаются комментарии (часть строки за символом '#' или является комментарием и игнорируется, также игнорируются пустые строки). Внутри файла задаются параметры вида name value Первым идет имя параметра, далее идет значение. В случае строковых значений строка может заключаться в одинарные или двойные кавычки. Внутри файла также могут содержаться секции (с любым уровнем вложенности), в этом случае в качестве имени выступает полный путь к величине с учетом вложенности. Для разбора конфигурационного файла также применяется объектный подход - парсер содержит список объектов, каждый из которых отвечает за разбор значения, соответствующего определенной переменной (объекты, унаследованные от класса Configitem). Когда при разборе файла парсер встречает очередное имя, то строится полное имя с учетом вложенности и ищется объект для разбора данного значения. Если соответствующий объект найден, то он получает запрос на разбор значения, соответствующего данной переменной. Если такого объекта нет, то вся строка просто игнорируется. Класс Configitem определяется следующим образом: о о. class Configitem : public Object // class to parse one config item ( public: Configitem ( const char * theName ) : Object ( theName ) { metaClass = &classlnstance; } virtual bool parseString ( const Strings str ) = 0; static MetaClass classinstance;
protected: String& str, bool * bool parseValue const bool parseValue const String& str, int * bool parseValue const String& str, float * bool parseValue const Strings str, String * }; Для удобства разбора стандартных типов можно использовать шаблоны, как показано ниже. е template <class Т> class BasicConfigltem : public Configitem { private: T * ptr; 'public: BasicConfigltem ( const char * theName, T * thePtr, T defValue ) : Configitem ( theName ) { ptr = thePtr; *ptr = defValue; } virtual bool parseString ( const String& str ) { return parseValue ( str, ptr ); } I'}; Сам парсер определяется следующим классом. ЬЕ Iclass ConfigParser : public Object |{ ^private: Array items,- // array of Configitem's public: ConfigParser () : Object ( "Config Parser" ) { metaClass = kclasslnstance; }
// parse data from source bool parse ( Data * data ) const; bool parse ( const char * fileName ) const; void additem ( Configitem * theltem ) { items.insert ( theltem ); } template cclass T> void additem ( const char * itemName, T * ptr, T defValue ) { items.insertNoRetain ( new BasicConfigltem <T> ( itemName, ptr, defValue ) ); } protected: void strip ( StringS: str ) Const; String buildFullName ( const Strings path, const Strings: name ) const; static MetaClass classinstance; Перед началом разбора конфигурационного файла парсеру необходимо задать имена переменных и их значения по умолчанию при помощи шаблонного метода additem. После этого вызывается метод parse для разбора файла, по окончании разбора зарегистрированным переменным будут присвоены значения (либо прочитанные из файла, либо значения по умолчанию). Ниже приводится пример разбора. Н float int String ConfiParser f ; i ; s ; parser; parser.additem ( II £ ll &f, 1. Of parser.additem ( "somesection/i", &i, 480 parser.additem ( "sectionl/section2/str", &s, String ( "abc" ) ); if ( (parser.parse ( "Arwen.cfg" ) ) (*sysLog) « "Error parsing Arwen.cfg logEndl;
За счет использования объектов для разбора отдельных строк файла ; обеспечивается возможность добавления переменных новых типов. На рис. 5.1 приводится UML-диаграмма классов, используемых для раз-, бора конфигурационных файлов. Рис. 5.1 Основные классы, не относящиеся напрямую к рендерингу и обработке ввода, изображены на диаграмме, представленной на рис. 5.2. Рис. 5.2
Глава 6. ОСНОВНЫЕ КЛАССЫ ДЛЯ РЕНДЕРЕРА. РАБОТА С РЕСУРСАМИ Класс Polygon3D Одним из самых главных классов в рассматриваемом проекте является класс Polygon3D, реализующий абстракцию выпуклого многоугольника в трехмерном пространстве. При этом данный класс поддерживает задание цветов и текстурных координат в вершинах многоугольника и ряд основных операций над ним. Такими операциями являются классификация многоугольника относительно плоскости и усеченной пирамиды видимости (viewing frustrum), определение того, является ли грань лицевой или нелицевой, отсечение по плоскости и усеченной пирамиде, нахождение пересечения многоугольника с заданными объектами, проверка принадлежности точки многоугольнику и различные преобразования многоугольника. Ниже приводится описание класса Polygon3D. class Polygon3D : public ( Obj ect protected: Plane * plane; // plane through this facet int numvertices; // current # of vertices int maxvertices; // storage allocated for И maxvertices int id; Vector3D * vertices; // polygon vertices Vector4D * colors; // color values ((RGBA) for // every vertex Vector2D * uvMap; / / texture coordinates И (u, v, 1) Texture * texture; И texture of this polygon Lightmap * lightMap; и it's light map BoundingBox boundingBox; / / polygon's bounding box и (AABB) Vector4D color; Mapping * mapping; И mapping of 3D coords / / to texture coords
public: Polygon3D (); Polygon3D ( const Polygon3D& poly ); Polygon3D ( const char * theName, int n ); Polygon3D ( const char * theName, int n, Vector3D * v, Vector2D * uv ); ~Polygon3D (); virtual bool isOk () const; virtual int init (); virtual int compare ( const Object * obj ) const; virtual long hash () const ( return id; } Polygon3D& operator = ( const Polygon3D& poly ) ; int getld () const { return id; } void setld ( int newld ) { id = newld; } const Plane * getPlane () const ( return plane; } void setPlane ( Plane * thePlane ) { delete plane; plane = thePlane; } void setColor ( const Vector4D& theColor ) { color = theColor; }
int getNumVertices () const { return numvertices; const Vector3D * getvertices () const { return vertices; } const Vector2D * getUVMap () const { return uvMap; } const Vector4D * getColors () const { return colors; } const Mapping * getMapping () const { return mapping; } const Vector3D& getNormal () const { return plane -> n; } bool isFrontFacing ( const Vector3D& org ) const { if ( testFlag ( PF_TWOSIDED ) ) return true; return plane != NULL ? ( plane -> classify ( org ) == IN_FRONT ) : false; const BoundingBox& getBoundingBox () const { return boundingBox;
Texture * getTexture () const { return texture; } Lightmap * getLightmap () const { return lightmap; } bool isEmpty () const { return numVertices < 3; } void setMapping ( const Mapping& ); void setTexture ( Texture * tex ) ; void setLightmap ( Lightmap * map ); void transform ( const Transform3D& ); void // apply affine transform transform ( const Matrix3D& ); void // apply linear transform translate ( const Vector3D& ); int // translate polygon classify ( const Plane& p ) const; void split ( const Plane& p, Polygon3D& front, void Polygon3D& back ) const; addVertex ( const Vector3D& v, void const Vector2D& uv, const Vector4D& color ); addVertex ( const Vector3D& v, void const Vector2D& uv ); addVertex ( const Vector3D& v ); void bool delVertex ( int index ); contains ( const Vector3D& v ) const; bool intersect ( const Polygon3D& ) const; int int clipByPlane ( const Plane& ) ; clipByFrustrum ( const Frustrum& ); float intersectByRay ( const Vector3D& org, const Vector3D& dir, Vector3D& pos); float getSignedArea () const; Vector3D getCenter () const; bool isTransparent () const;
float getDistEstimate ( const Vector3D& dir, const Vector3D& from ) const { int mask = computeNearPointMask ( dir ) ,- return (boundingBox,getVertex ( mask ) & dir) - - (from & dir) ; } float getDistEstimate ( const Vector3D& dir, float offset, int nearPointindex ) { return (boundingBox.getVertex(nearPointindex) & dir) - - offset; } void projectFromPointOntoPlane ( const Vector3D& org, const Plane& plane ) ,- float closestPoint2Boundary ( const Vector3D& p, Vector3D& res ) const; void realloc ( int newMaxVertices ); void computePlane (); void buildBoundingBox (); void clear (); Vector3D mapTextureToWorld ( const Vector2D& tex ) const; // get range of texture coordinates void getTextureExtent ( Vector2D& texMin, Vector2D& texMax ) const; static MetaClass classinstance; }; Как видно из приведенного описания, объект класса Polygon3D содержит в себе массив вершин (vertices), массив значений цвета в вершинах (colors), текстурные координаты в вершинах (uvMap). Также с каждым многоугольником связана содержащая его плоскость (plane), ограничивающий прямоугольный параллелепипед (boundingBox), служащий для ускорения различных проверок, текстура (texture) и карта освещенности (lightmap). Также можно задать глобальный цвет для всего многоугольника (color), ко
торый будет модулироват ь значения цвета в вершинах и способ вычисления текстурных координат (mapping). Для всех этих величин представлены соответствующие спсобы доступа (set- и gez-мстоды). Метод init осуществляет начальную инициализацию многоугольника, а именно вычисляет плоскость, содержащую его. Метод isFrontFacing определяет, является ли данный многоугольник лицевым по отношению к наблюдателю, расположенному в заданной точке. Можно использовать флаг PF_TWOSIDED для отключения проверки - многоугольник, у которого установлен этот флаг, всегда считается лицевым. Методы transform позволяют применять к многоугольнику произвольные линейные и аффинные преобразования. Метод translate служит для переноса многоугольника на заданный вектор и отличается от метода transform только оптимизацией. Для определения положения многоугольника по отношению к заданной плоскости служит метод classify, который возвращает одно из следующих значений: IN FRONT, IN BACK, INPLANE и IN BOTH. Метод split строит два многоугольника, являющиеся результатом разбиения данного многоугольника заданной плоскостью, не изменяя при этом исходного многоугольника. Методы addVertex и delVertex служат для добавления и удаления вершин многоугольника. Метод intersect проверяет наличие пересечения данного многоугольника другим выпуклым многоугольником, используя алгоритм из гл. 3. Методы clipByPlane и clip By Frustrum служат для отсечения многоугольника по заданной плоскости и усеченной пирамиде соответственно. Метод closestPointToBoundary определяет точку на границе многоугольника, ближайшую к данной. Метод getCenter возвращает середину многоугольника, вычисляя ее как среднее арифметическое из координат вершин. Метод getSignedArea возвращает площадь многоугольника со знаком, знак определяется ориентацией многоугольника. Метод getDistEstimate служит для оценки расстояния до многоугольника вдоль заданного вектора. i Метод mapTextureToWorld служит для определения точки на многоугольнике, соответствующей заданным текстурным координатам (при этом используется mapping). I Метод getTextureExtent возвращает минимальные и максимальные ^значения каждой из текстурных координат для всего многоугольника. Рассмотрим подробнее отсечение многоугольника по плоскости (рис. 6.1).
Пусть имеется плоскость л: (р,n) + d = 0. Тогда найдем / = (v(, л) + d -расстояние со знаком от соответствующей вершины до плоскости л. Если все эти величины имеют одинаковый знак, то данный многоугольник целиком находится по одну из сторон плоскости (какую именно, зависит от знака этих величин). В противном случае ищутся такие ребра, для которых значения в концах будут разных знаков. Эти ребра пересекаются плоскостью л и соответствующие точки пересечения задают разбиение множества исходных вершин на множество вершин, лежащих в положительном полупространстве, и множество вершин, лежащих в отрицательном полупространстве, при этом сами точки разбиения войдут в оба этих множества. Рассмотрим очередное ребро v, v1+1 многоугольника (рис. 6.2).
По отношению к плоскости л может быть четыре различных случая (рис. 6.3). в г Рис. 6.3 Тогда в случае а ребро целиком попадает в результирующий многоугольник, т. е. точка vftl добавляется к списку вершин усеченного многоугольника. В случае б ребро пересекает плоскость тс и в усеченный многоугольник попадает только часть ребра от точки v; до точки v, и поэтому к списку вершин добавляется точка пересечения v'. В случае в весь отрезок отсекается и в список вершин не попадает ни одной вершины из этого отрезка. В случае г в список добавляется как точка пересечения v , так и точка vi+1.
Отрезок, соединяющий вершины v, и v(t), можно записать в параметрическом виде как v = ?v, + (l-f)v,+1, (6.1) где параметр t принимает значения от 0 до 1. Тогда для определения точки пересечения этого отрезка с плоскостью л достаточно подставить уравнение (6.1) в уравнение плоскости и найти из него значение параметра. (6.2) В результате этого определяются все вершины, задающие часть многоугольника, лежащую в положительном полупространстве. Аналогичным образом строится и часть многоугольника, лежащая в отрицательном полупространстве. Еще одним методом является определение принадлежности точки. Данная операция осуществляется методом contains и достаточно подробно описана ранее. Еще одним знакомым методом является определение пересечения многоугольника с заданным лучом. Это делается методом intersectByRay и также происходит аналогично описанному в гл. 3. Методы для классификации и отсечения относительно усеченной пирамиды являются оптимизированными версиями операций классификации и отсечения по плоскости. Одним из способов задания текстурных координат (помимо явного задания их в каждой вершине) служит использование объекта класса Mapping. Данный объект задает закон соответствия текстурных координат точкам многоугольника. Здесь будет использоваться простейший аффинный закон. Е1 class Mapping { private: Vector3D uAxis; Vector3D vAxis; Vector3D dir; float uOffs; float vOffs; float inv [2][2] ; // direction of projection // matrix to unmap from 2D to 3P
public: Mapping ( const Vector3D& u, float uOffset, const Vector3D& v, float vOffset ); Vector2D map ( const Vector3D& pos ) const { return Vector2D ( uOffs + (pos & uAxis), vOffs + (pos & vAxis) ); } Vector3D unmap ( const Vector2D& tex, const Plane& plane ) const; }; ' Метод map служит для задания текстурных координат заданной точке пространства. § Метод иптар служит для определения точки на плоскости, соответствующей заданным текстурным координатам и является обращением метода .тар. Заметим, что однозначное обращение этого преобразования невозможно, поэтому необходимо задать плоскость, на которой ищется соответствующая точка. Введенный класс Polygon3D станет фактически одним из основных Классов, на основе которых будет строится рендерер. Он инкапсулирует р себе основные операции, которые понадобятся нам в дальнейшем. Данный класс корректно поддерживает создание и уничтожение многоугольников, их присваивание, не приводя к утечкам памяти. Класс сам управляет распределением памяти под вершины, текстурные координаты И цвета. Чтобы каждое добавление вершины не приводило к выделению И копированию памяти, память выделяется блоками по 8 элементов, и если уже отведенной памяти достаточно, то повторного выделения не происходит. Класс Texture Еще одним полезным классом, который мы будем активно использовать (алее, является класс Texture, являющийся абстракцией произвольной текстуры, применяемой для наложения на грань. Поскольку текстуры могут иметь различную внутреннюю структуру I число цветовых компонент, количество битов на каждую компоненту и по-Ыдок их следования), то удобно, чтобы соответствующий объект содержал |цутри себя не только идентификатор и тип текстуры в OpenGL, но также | некоторую структуру, описывающую структуру пикселов в памяти. Ниже приводится описание такой структуры.
class PixelFormat { public: unsigned redMask; // bit mask for red color bits int redShift; // position of red bits in pixel // data int redBits; // # of bits for red field unsigned greenMask; // bit mask for green color bits int greenShift; // position of green bits in pixel // data int greenBits; // # of bits for green field unsigned blueMask; // bit mask for blue color bits int blueShift; // position of blue bits in pixel // data int blueBits; unsigned alphaMask; int alphaShift; int alphaBits ,- int bitsPerPixel; // ' # of bits per pixel int bytesPerPixel; // ' # of bytes per single pixel PixelFormat () {} PixelFormat ( unsigned theRedMask, unsigned theGreenMask, unsigned theBlueMask, unsigned theAlphaMask = 0 ) { completeFromMasks ( theRedMask, theGreenMask, theBlueMask, theAlphaMask ); } void completeFromMasks ( unsigned theRedMask, unsigned theGreenMask, unsigned theBlueMask, unsigned theAlphaMask = 0 ) ; intrgbaToInt ( int red, int green, int blue, int alpha = 0 ) const { return ( (blue» ( 8-blueBits) ) «blueShif t) | ((green»(8-greenBits))<<greenShift) | ( (red» ( 8-redBits) ) <<redShif t) | ( (alpha» ( 8-alphaBits) ) <<alphaShif t) ,- ,-}
int getRed ( int color ) const { return ((color >> redshift) « (8 - redBits)) & redMask; } intgetGreen ( int color ) const ( return ((color >> greenShift) << (8 - greenBits)) & greenMask; } intgetBlue ( int color ) const ( return ((color >> blueShift) << (8 - blueBits)) & blueMask; } intgetAlpha ( int color ) const { return ((color >> alphaShift) << (8 - alphaBits)) & alphaMask; } } ; Здесь поля redMask, greenMask, blueMask и alphaMask являются битовыми масками для красной, зеленой, синей и a-компонент пиксела. Поля redBits, greenBits, blueBits и alphaBits содержат количество битов на каждую из компонент RGBA. Поля redShift, greenShift, blueShift и alphaShift содержат смещение в битах соответствующих компонент от начала пиксела. Поля bitsPerPixel и bytesPerPixel содержат количество битов и байтов на 1 пиксел. Метод completeFromMasks осуществляет вычисление всех полей структуры по переданным маскам полей. Метод rgbaTolnt по заданным 8-битовым значениям компонент строит 5итовое представление пиксела в виде 32-битового беззнакового целого Методы getRed, getGreen, getBlue и getAlpha служат для доступа значениям компонент по битовому представлению пиксела (в виде 32-итового целового числа). При этом возвращаемые значения приводятся 8-битовому представлению.
Используя эту структуру, легко построить класс, отвечающий за хранение и представление текстуры. Ниже приводится его описание. class Texture : public Object { protected: PixelFormat format; void * data; int width; int height; unsigned id; // id for use in OpenGL // glBindTexture int glFormat; // OpenGl format of texture // (GL_ALPHA, GL_RGB, // GL_RGBA & etc bool mipmap; // whether to create and use // mipmaps public: Texture ( const char * theName, int theWidth, int theHeight, const PixelFormat& theFormat ); -Texture () { delete data; } virtual bool isOk. () const. { return width > 0 && height > 0 && data != NULL; } intgetWidth () const { return width; } intgetHeight () const { return height; } const void * getData () const { return data; }
bool isTransparent О const { return format.bitsPerPixel == 8 || format.bitsPerPixel == 32; } int getNumComponents () const { return format.bytesPerPixel; } bool isMipmapped () const { return mipmap; } void setMipmap ( bool flag ) { mipmap = flag; } unsigned& getld () { return id; } int getOpenGLFormat () const { return glFormat; } void setOpenGLFormat ( int fmt ) { glFormat = fmt; } void upload (); void unload (); int getOpenGLType () const; // write 32-bit pixel buffer to Texture line void writeLine ( int y, long * buf, long * palette = NULL ) void readLine ( int y, long * buf ); static MetaClass classinstance;
Поле format содержит описание формата, в котором хранятся пикселы в массиве data. Поля width и height содержат размер текстуры в пикселах. Поля id и glFonnat содержат идентификатор и тип текстуры в OpenGL. Поле mipmap отвечает за использование пирамидального фильтрования при загрузке текстуры в графический ускоритель. Сама загрузка осуществляется при помощи метода upload. Для загрузки текстуры из файла мы будем пользоваться методом write-Llne. Данный метод получает на вход номер строки и массив значений пикселов для этой строки в виде 32-битового RGBA-значения и должен перевести эти значения во внутренний формат текстуры в массиве data. Использование записи пикселов на уровне строки (а не отдельных пикселов) объясняется исключительно соображениями эффективности. Метод readLine переводит строку пикселов из внутреннего представления в 32-битовый RGBA-формат. Одними из наиболее важных методов этого класса являются методы upload и unload, соответственно регистрирующие и дерегистрирующие данную текстуру в OpenGL. Класс ResourceManager Поскольку в нашем проекте понадобится работа с различными типами ресурсов (текстуры, модели, описание сцены), то удобно сразу реализовать некоторый централизованный механизм для унифицированной работы с различными типами ресурсов. При этом следует иметь в виду как то, что сам файл с ресурсом может храниться различным образом (в виде обычного файла, внутри специального файла ресурсов, например рак, или же внутри архива, например zip), так и то, что существуют различные форматы файлов с ресурсами (например, текстура может храниться в форматах рсх, bmp, gif, jpeg, png, tga и многих других). Поэтому было бы удобно отделить способ доступа к файлу с ресурсом от способа его декодирования (процесса построения объекта по загруженному файлу). Стандартным способом для достижения этого [12, 14] является вынесение изменяющейся функциональности в отдельные объекты. В нашем случае возникает два типа таких объектов - объекты, обеспечивающие доступ к данным, и объекты, обеспечивающие декодирование данных из различных форматов. Класс ResourceSource служит абстракцией доступа к файлу с данными. На его основе могут быть построены классы, позволяющие загружать фай
лы как т обычной файловой системы, так и из составных файлов различных форматов (например, ркЗ). Ниже приводится описание этого класса. 3 class Resourcesource : public Object { publi C : ResourceSource ( const char * theName ) : Object ( theName ) {} virtual Data * getFile ( const Strings name ) { return NULL; } static MetaClass classinstance; } ; Метод getFile возвращает указатель на объект класса Data, содержащий загруженный образ файла или NULL, если файл найти не удалось. Использование класса Data для доступа к данным объясняется соображениями удобства и инкапсуляции. Этот класс содержит ряд операций, которые будут использоваться при декодировании ресурсов. Ниже приводится его описание S class Data : public Object { private: unsigned char * bits; int length; int pos; public: Data () : Object ( "" ) { bits = NULL; length = 0; pos = 0; metaClass = &classlnstance; } Data ( const char * theName, void * ptr, int len ) : Object ( theName ) { bits = (unsigned char *) ptr,-
length = len; pos = 0; metaClass = &classlnstance; } virtual bool isOk () const { return bits != NULL; } bool isEmpty () const { return pos >= length; } int getLength () const { return length; int getByte () { if ( pos < length ) return bits [pos++]; else return -1; short getShort () { if ( pos + 1 >= length ) return -1; short v = *(short *) (bits + pos); pos += 2; return v; unsigned short getUnsignedShort () { if ( pos + 1 >= length ) return -1; unsigned short v = *(unsigned short *) (bits + pos);
pos += 2; return v; } long getLong () { if ( pos + 3 >= length ) return -1; long v = *(long *) (bits + pos); pos += 4; return v; } unsigned long getUnsignedLong () { if ( pos + 3 >= length ) return -1; unsigned long v = *(unsigned long *) (bits + pos); pos += 4; return v; } void * getPtr () const { return bits + pos; } void * getPtr ( int offs ) const { if ( offs < 0 || offs >= length ) return NULL; return bits + offs; } int seekCur ( int delta ) { pos += delta;
if ( pos > length ) pos = length; if ( pos < 0 ) pos = 0; return pos; } int seekAbs ( int offs ) { pos = offs; if ( pos > length ) pos = length; if ( pos < 0 ) pos = 0; return pos ; } int getBytes ( void * ptr, int len ); bool getString ( String& str ); static MetaClass classinstance; } ; Методы get служат для доступа к элементу данных заданного типа. При этом указатель внутри объекта автоматически сдвигается к следующему элементу. Методы seek служат для явного перемещения указателя. На компакт-диске содержатся реализации классов OsFileSystem для доступа к обычным файлам, PakFileSystem, служащего для доступа к ресурсам внутри файла рак игр Quake и Quake II, и ZipFileSystem, служащего для доступа внутри zip-архивов (и соответственно р&З-файлов игры Quake ПГ). При этом удобно иметь набор объектов-поставщиков ресурсов, и тогда каждый запрос на получение ресурса по очереди передается каждому из таких объектов до тех пор, пока требуемый ресурс не будет найден (или не будет исчерпан этот список). Подобный подход является примером паттерна "Цепочка ответственности" [12]. Для декодирования ресурсов также удобно использовать объекты - каждый объект соответствует определенному формату ресурсов и пытается
J Глава 6. Основные классы для рендерера. Работа с ресурсами г ........................................................ ’ ( п [ его декодировать. При этом все такие декодеры должны быть унаследованы 1 от приводимого ниже абстрактного класса ResourceDecoder. Ij. 'class ResourceDecoder : public Object { private: ResourceManager * resManager; public: ResourceDecoder ( const char * theName, ResourceManager * rm ) : Object ( theName ) { resManager = rm; metaClass = &classlnstance; } ResourceManager * getResourceManager () const { return resManager; } // try to build object from data, // returns NULL on failure virtual Object * decode ( Data * ) = 0; // kind of a hunt, whether name corresponds // to decoded type of data virtual bool checkExtension ( const Strings theName ) = 0; static MetaClass classinstance; } ; Как видно из описания, метод decode по загруженному образу файла (содержащемуся в объекте класса Data) строит объект, соответствующий содержанию файла (например, объект класса Texture для файла с текстурой), или выдает NULL, если он не может построить объект данного класса по переданным данным. Для унификации доступа к ресурсам удобно "завернуть" весь доступ внутрь специального объекта. Основной задачей такого объекта является предоставления всем остальным частям системы доступа к ресурсам, скрывая при этом особенности, связанные с форматом ресурса и его местоположением. Интерфейс объекта ResourceManager приводится ниже.
class ResourceManager : public Object { private: Array sources; // file systems used // to retrieve data Array decoders; // registered object loaders Array objects; // loaded objects PixelFormat defFormats [3]; • // Formats for RGB, RGBA and // single-component textures public: ResourceManager ( const char * theName ); int registersource int unregisterSource int registerDecoder int unregisterDecoder data * getResource object * getObject texture * getTexture font * getSystemFont void freeObject void upload ( ResourceSource * system ); ( const Strings theName ); ( ResourceDecoder * loader ); ( const Strings theName ); ( const Strings theName ); ( const Strings theName ); ( const Strings theName ); () ; ( const Strings theName ); 0 ; void setRgbFormat ( const PixelFormatS format ) { defFormats [0] = format; } void setRgbaFormat ( const PixelFormatS format ) { defFormats [1] = format; } void setMonoFormat ( const PixelFormatS format ) { defFormats [2] = format; } const PixelFormatS getRgbFormat () const { return defFormats [0] ; }
const PixelFormat& getRgbaFormat () const ( return defFormats [1]; } const PixelFormat& getMonoFormat () const { return defFormats [21; } static MetaClass classinstance; } ; В массиве sources содержится список зарегистрированных поставщиков ресурсов, и когда поступает запрос на ресурс, то ResourceManager перебирает всех поставщиков ресурсов для нахождение запрашиваемого файла. \Л. Data * ResourceManager : : getResource ( const Strings theName ) { Data * data; for ( Array :: Iterator it = sources.getlterator (); !it.end (); ++it ) { ResourceSource * source = (ResourceSource *) it.value (); if ( ( data = source -> getFile ( theName ) ) != NULL ) return data; } (*sysLog) << "Cannot locate " « theName « logEndl; return NULL; } При декодировании ресурсов используется эта же схема: имеется список зарегистрированных декодеров decoders и, после того как файл с ресурсом был прочитан, им всем по очереди передается запрос на декодирование файла. Метод checkExtension служит для ускорения нахождения нужного декодера. Обычно в расширении файла с ресурсом уже содержится информация о типе необходимого декодера (например, файлу flare.tga соответствует тип tga и он должен декодироваться при помощи объекта класса TgaDecoder); поэтому перед передачей всем декодерам запроса на декодирование
им предлагается проверить имя ресурса на соответст вие типу ресурса, и если находится декодер, идентифицирующий ресурс как свой, то он получает приоритетное право на его декодирование. В случае если такая схема не срабатывает, то по очереди перебирваются все объекты-декодеры. Е1 Object * ResourceManager :: getobject ( const String& theName ) { Object * object = objects.getObjectWithName ( theName ); if ( object != NULL ) return object -> retain (); // the texture has not been loaded yet Data * data = getResource ( theName ); if ( data == NULL ) return NULL; for ( Array :: Iterator it = decoders.getlterator (); lit.end (); ++it ) { ResourceDecoder * decoder = (ResourceDecoder *) it.value (); if ( decoder -> checkExtension ( theName ) ) if ( ( object = decoder -> decode ( data ) ) != NULL ) { objects.insert ( object ); object -> setName ( theName ); data -> release (); return object; } } for ( it = decoders.getlterator (); lit.end (); ++it ) { ResourceDecoder * decoder = (ResourceDecoder *) it.value (); if ( ( object = decoder -> decode ( data ) ) != NULL ) { object -> setName ( theName ); data -> release ();
objects.insert ( object ); return object; } if ( data != NULL ) data -> release (); (*sysLog) << "Cannot load 11 << theName << logEndl; return NULL; } Для точного определения типа возвращенного ресурса удобно воспользоваться конструкцией dynamic_cast языка C++ (для этого в большинстве компиляторов необходимо включить поддержку RTTI), хотя этого можно также добиться используя метод is KindOfClass класса Object. На компакт-диске содержится исходный текст декодеров для ряда графических форматов, таких, как bmp,pcx, gif, tga, wal,png и jpeg. Ниже приводится UML-диаграмма классов, используемых для доступа к ресурсам. Рис. 6.4 С помощью введенных классов легко осуществляется работа с различными типами ресурсов, находящихся в разных местах. При этом весь механизм для работы с ресурсами собран в одном месте и работает совершенно прозрачно для всех частей системы. Переход от хранения ресурсов в одном месте (например, файловой системе на компьютере разработчика) к другому мету и способу (например, внутри zip-архива у пользователя) происходит практически безболезненно. Более того, система может осуществлять поиск ресурсов сразу в нескольких
местах в заданном порядке (при помощи задания нескольких объектов класса ResourceSource). Такой подход позволяет легко добавлять в систему поддержку как новых форматов ресурсов (например, других форматов текстур), так и новых типов ресурсов. Далее мы добавим загрузку сцен и моделей, используя этот же маханизм. При этом добавление новых типов ресурсов совершенно не затрагивает функционирование остальных частей системы.
Глава 7. ПИШЕМ ПОРТАЛЬНЫЙ РЕНДЕРЕР (часть I) С этой главы мы начинаем писать портальный рендерер. К концу этой главы мы напишем простейший портальный рендерер с использованием библиотеки OpenGL. Полный исходный код, а также соответствующий проект для среды Visual C++ содержатся на компакт-диске. Выбор именно портальной модели объясняется как ее простотой и гибкостью, так и отсутствием необходимости сложной и длительной предварительной обработки сцены, что часто встречается во многих играх. Еще одним преимуществом портальной модели является возможность вносить изменения в сцену прямо на ходу. В пользу метода порталов также говорят и те игры, которые используют его для своей работы: Unreal, Serious Sam и многие другие. Кроме того, существуют возможности для расширения классической портальной модели, позволяющей эффективно использовать графический ускоритель и работать с различными типами сцен. Некоторые такие расширения мы рассмотрим в этой книге. Тем не менее в гл. 11 приводится рендерер для уровней игры Quake II, использующей схему BSP/PVS для определения видимости в сцене. Схема ’’Модель - контроллер - вид” При написании нашего рендерера для организации ввода-вывода мы будем использовать концепцию "Модель - вид - контроллер" (Model-View-Controller, MVC [12]), впервые применявшуюся при построении пользовательских интерфейсов в среде Smalltalk и с тех пор получившей широкое распространение. В этой концепции разделяются объект, отвечающий за графическое представление объекта (вид), сама модель объекта (модель) и контроллер, отвечающий за обработку ввода пользователя и визуальное отображение модели (рис. 7.1).
Рис. 7.1 Использование подобного подхода ведет к разделению ответственности между частями системы и облегчает как разработку всей системы в целом, так и последующее внесение в нее изменений по мере ее развития. Основные объекты (модель, контроллер, вид) могут разрабатываться и тестироваться независимо друг от друга. Кроме того, любой из них может быть заменен другим объектом при условии сохранения интерфейса. В качестве модели будет выступать класс, содержащий реализацию трехмерной сцены. Он описывается следующим интерфейсом: Е1 class Model : public Object ( protected: Vector3D pos; // starting pos of // the viewer in the model float yaw, pitch, roll; // euler angles of the // viewer public: Model ( const char * theName ) : Object ( theName ) {} // methods for rendering and // getting potential colliders virtual void virtual void virtual void const Vector3D& ( return pos; } render ( View& view, const Camera^ camera ) {} getColliders ( const BoundingBox& area, Array& colliders ) {} update ( Controller *, float systemTime ) {} getPos () const
float getYaw () const { return yaw; } float getPitch () const { return pitch; } float getRoll () const { return roll; } void setstart ( const Vector3D& v, float y, float p, float r ) { pos = V; yaw = у; pitch = p; roll = r; } static MetaClass classinstance; } ; Величина pos задает начальное положение наблюдателя в сцене, a yaw, pitch и roll - его начальную ориентацию при помощи углов Эйлера. Метод render служит для отображения модели в заданном виде. Метод update служит для управления анимацией сцены - модель постоянно получает это сообщение и может использовать это для управления изменяющимися объектами. Метод getColliders служит для определения объектов в заданной области сцены для последующей проверки на столкновение. Следующим классом в нашей иерархии будет класс View - абстракция устройства для вывода графической информации. Внутри него инкапсулируется информация о графическом устройстве, его видеорежимах, а также основные методы для работы с этим устройством. В идеале он должен полностью инкапсулировать все обращения к устройству внутри себя, но поскольку мы уже решили использовать OpenGL, то писать еще один слой абстракции поверх OpenGL было бы слишком сложным и громоздким и не дало бы ощутимых преимуществ.
Хотя если планируется поддержка сразу нескольких интерфейсов вывода, то удобно весь доступ к устройству инкапсулировать внутри объекта View. Тогда смена интерфейса (например, переход от OpenGL к Direct3D) означает только смену объекта "Вид" (с OpenGIView на Direct3DView) и полностью скрыто как от остальных частей системы, так и от игрока. Далее для вывода 1’рафической информации мы будем использовать только OpenGL и некоторые классы будут содержать в себе прямые вызовы OpenGL (а некоторые будут обращаться через класс View). Это позволит упростить интерфейс нашей системы и сделать его более наглядным. Ниже приводится описание этого класса. gi class View : public Object ( protected: int width; int height; int bitsPerPixel; Controller * controller; Array modeList; // array of VideoMode's public: View ( const char * aName ) : Object ( aName ) { width = 0; // unknown height = 0; // unknown bitsPerPixel = 0; // unknown controller = NULL; metaClass = &classlnstance; } virtual bool isOk () const { return false; } virtual void * getWindow () const { return NULL; } virtual char * getVendor 0 = 0; virtual char * getRenderer () = 0; virtual char * getVersion () = 0;
virtual int getChar () = 0; virtual bool getKeyboardState ( KeyboardState& ) = 0; virtual bool getMouseState ( MouseState& ) = 0; virtual virtual virtual virtual virtual virtual virtual virtual virtual void draw ( const Polygon3D& poly ) void simpleDraw , ( const Polygon3D&, int mask = 0 ) {} void setFullScreen ( bool toFullScreen ) {} void setsize ( int newWidth, int newHeight ) {} void startOrtho () {} void endOrtho () {} void bindTexture ( Texture * ) {} void blendFunc ( int src, int dst ) {} Texture * getScreenShot () const { return NULL; } virtual void apply ( const Transform3D& ) {} virtual void apply ( const Camerai ) {} const Array& getModeList () const { return modeList; } int getWidth () const ( return width; } int getHeight () const { return height; } int getBitsPerPixel () const { return bitsPerPixel; } Controller * getController () const ( return controller; }
void setcontroller ( Controller * cntrl ) { controller = cntrl; } enum BlendModes { biriNone = -1, bmZero = 0, bmOne = 1, bmSrcColor = 2, bmOneMinusSrcColor = 3, bmDstColor = 4, bmOneMinusDstColor = 5, bmSrcAlpha = 6, bmOneMinusSrcAlpha = 7, bmDstAlpha = 8, bmOneMinusDstAlpha = 9 }; enum DrawMasks { useColors = 1, useLightmap = 2 static MetaClass classinstance; Методы getVendor, getRender и getVersion предназначены для получения информации об используемом графическом ускорителе и его драйвере. Методы getEvent, getKeyboardState и getMouseState предназначены дйя чтения ввода пользователя. Метод getEvent возвращает указатель на объект, инкапсулирующий очередное событие (нажатие или отпускание клавиши, получение очередного символа) внутри себя, или NULL, если с момента последнего обращения никаких событий не произошло. Методы getKeyboardState и getMouseState возвращают информацию о состоянии клавиатуры и мыши. Метод draw служит для вывода заданного многоугольника с учетом цвета и текстуры и опирается на абстрактный метод simpleDraw. Метод setFullScreen управляет переключением между полноэкранным и оконным режимами работы. Метод setSize служит для изменения размеров окна в оконном режиме.
Метод apply служит для задания преобразования координат и проектирования, соответствующий камере. Еще одной важной обязанностью этого класса (помимо создания окна и его настройки, поддержки полноэкранного режима и т. п.) является чтение информации с клавиатуры и мыши. Для этого будет использоваться объект следующего вида: Е class InputReader : public Object { public: InputReader ( View *, const char * theName ) : Object ( theName ) {} virtual bool getKeyboardState ( KeyboardState& ) = 0; virtual bool getMouseState ( MouseState& ) = 0; }; Информация о состоянии мыши и клавиатуры возвращается при помощи объектов следующих классов: Е class Keyboardstate private: | char keys [256] ; Jpublic: Keyboardstate () { memset ( keys, 1 \0', sizeof ( keys ) ); } char * getKeys () { return keys; } bool isKeyPressed ( int keyCode ) const { return (keyCode >= 0 && keyCode < sizeof ( keys )) ? (keys [keyCode] != 0) : false; }
class Mousestate { public: int mouseX; int mouseY; int wheel; int mouseFlags public: Mousestate () { mouseX = 0; mouseY = 0 ; wheel = 0; mouseFlags = 0; } bool isMouseButtonPressed ( int buttonNo ) const { return (mouseFlags & (1 « buttonNo)) != 0; } intgetMouseX () const { return mouseX; } intgetMouseY () const { return mouseY; } int getWheel () const { return wheel; } void setstate ( int x, int y, int wh, bool bl, bool b2, { mouseX = mouseY = wheel = mouseFlags = bool b3, bool x; y; wh; (bl ? 1 : 0 ) | b4 ) (b2 ? 2 : 0 ) | (b3 ? 4 : 0 ) | (b4 ? 8 : 0 ); }
Для обозначения клавиш удобно ввести независимые обозначения (которые приведены в файле keys.h). За предоставление данного объекта отвечает объект класса View, и его методы опроса состояния мыши и клавиатуры используют данный объект. Это связано с тем, что при изменении некоторых параметров вывода (разрешения экрана, битовой глубины и т. п.) иногда может возникнуть необходимость в создании нового объекта InputReader. При этом сам объект view отвечает за создание объекта для чтения информации с клавиатуры и мыши (как это ни странно, объект View является наиболее подходящим местом для создания такого объекта, так как он уже содержит в себе привязку к оконной системе, а, кроме того, объекты Model и Controller должны быть максимально абстрагированы от знания конкретного способа организации ввода-вывода). В нашем рендерере мы будем использовать класс OpenGIView для работы с графическим ускорителем и оконной системой. Этот класс предназначен для работы с библиотекой OpenGL в операционной системе Windows. Ниже приводится описание класса OpenGIView, служащего для обеспечения работы с библиотекой OpenGL в Windows. class OpenGIView : public View { private: bool fullscreen; HINSTANCE hlnstance; HWND hWnd ; HDC hDC; HGLRC hRC; bool visible; float zNear; float zFar ; unsigned lastTextureld; char keys [256] ; int mouseX; int mouse!; int mouseLeft, mouseRight, mouseMiddle; Camera * camera; InputReader * reader; Queue public: events; OpenGIView ( const char * theName, HINSTANCE hlnst, Camera * theCamera, int theWidth, int theHeight, int bpp, bool toFullScreen = true ) ;
-OpenGlView (); virtual bool isOk () const { return hWnd != NULL && hDC != NULL && hRC != NULL; } virtual virtual void void doLock (); doUnlock (); virtual bool registerTexture ( Texture * ); virtual void simpleDraw ( const Polygon3D& poly, int mask = 0 ) ; virtual void initGL () ; virtual void setFullScreen ( bool toFullScreen ); virtual void setsize ( int newWidth, int newHeight ); virtual void startOrtho () virtual void endOrtho () virtual void bindTexture ( Texture * ); virtual void blendFunc ( int src, int dst ); virtual void apply ( const Transform3D& ); virtual void apply ( const Camera& ); virtual void * getWindow () const { return hWnd; } virtual Event * getEvent (); virtual bool getKeyboardState ( KeyboardState& ); virtual bool getMouseState ( MouseState& ); virtual char * getVendor (); virtual char * getRenderer (); virtual char * getVersion (); virtual Texture * getScreenShot () const; bool isVisible () const { return visible; )
Camera * getCamera () const { return camera; } static int getBitDepth () ; // get bit depth of current display mode static MetaClass classinstance; protected: bool changeScreenResolution ( int theWidth, int theHeight, int bpp void reshape ( int theWidth, int theHeight ); bool createWindow ( int theWidth, int theHeight, int bpp, bool isFullScreen, int depthBits =32 ); bool destroyWindow (); bool done () ; // clear handles and retrun false void getDisplayModes (); friend static LRESULT CALLBACK windowProc ( HWND hWnd, HINT uMsg, WPARAM wParam, LPARAM iParam ); Данный класс обеспечивает использование двойной буферизации для получения плавной анимации без артефактов. Переключение буферов осу-дцествляется при помощи вызовов lock/unlock. Когда происходит разблокирование объекта (lockCount =- 0), то буферы автоматически переключаются (это реализовано в методе doUnlock). Таким образом, процесс построения изображения может быть представлен следующим образом: yiew -> lock (); nodel -> render ( view ); ziew -> unlock (); Поскольку методы lock/unlock поддерживают вложенные вызовы, ?о любой объект, которому необходимо что-либо нарисовать, осуществляет Локирование объекта "Вид", производит рисование, по завершении которо-ю объект "Вид" разблокируется.
Для организации ввода в операционной системе Windows можно использовать библиотеку Directlnput, "завернув" ее предварительно в соответствующий класс, приводимый ниже. ТУ class DirectXInputReader : public InputReader { LPDIRECTINPUT inputobject; LPDIRECTINPUTDEVICE keyboardDevice; LPDIRECTINPUTDEVICE mouseDevice; HINSTANCE hlnstance; HWND hWindow; public : DirectXInputReader ( View * view, const char * aName ); -DirectXInputReader () ; virtual bool isOk () const { return inputobject != NULL && keyboardDevice != NULL && mouseDevice != NULL; } virtual bool getKeyboardState ( KeyboardState& ); virtual bool getMouseState ( MouseState& ); static MetaClass classinstance; }; Для передачи контроллеру информации о произошедших событиях, источником которых служит оконная система (или пользователь), мы будем применять объекты, унаследованные от общего корня Event (рис. 7.2). Каждому из различных типов таких сообщений соответствует свой класс в иерархии. Рис. 7.2
Ниже приводится описание базового класса Event. 'Г-1 л. class Event : public Object { public: Event () : Object ( "" ) { metaClass = &classlnstance; } virtual int handle ( Controller * ) = 0; static MetaClass classinstance; }; Данный класс использует так называемую двойную диспетчеризацию -метод handle осуществляет передачу сообщения контроллеру путем вызова соответствующего его метода. Какой именно метод у контроллера вызывается, определяется конкретным классом сообщения (т. е. подкласс сам знает, какой метод следует вызвать). Н intKeyEvent :: handle ( Controller * controller ) { ! if ( controller != NULL ) return controller -> handleKey ( keyCode, pressed ); return controllerContinue; intCharEvent :: handle ( Controller * controller ) { ’ if ( controller != NULL ) return controller -> handlechar ( ch ); return controllerContinue; } Следующим классом является Controller, отвечающий за обработку ввода от пользователя. Его задачей является координация остальных композит системы, поэтому он должен предусматривать наибольшую гибкость. Интерфейс данного класса устроен следующим образом:
class Controller : public Object { protected: Model * model; // model we're rendering View * view; // view, we're using for rendering Camera * camera; // camera used for rendering Timer Console Font int float float Vector3D float bool Vector3D * timer; * console; * font; numFrames; // # of rendered frames lastFrameTimes [8]; / / time of processing of the last 8 frames fps ; pos; // position of player yaw, pitch, roll; / / euler angles of player resourcesUploaded; velocity; public: Controller ( const char * theName, Model * theModel, View * theView, Camera * theCamera ); -Controller (); Timer * getTimer () const { return timer; } Model * getModel () const { return model; } View * getView () const { return view; } Camera * getCamera () const ' { return camera; }
Console * getConsole () const { return console; } float getFps () const { return fps; } int getNumFrames () const { return numFrames; } float getYaw () const { return yaw; } float getPitch () const { return pitch; } float getRoll () const { return roll; } const Vector3D& getPos () const { return pos; } void setview ( View * theView ) void setModel ( Model * theModel ) void setCamera ( Camera * theCamera ) virtual bool isOk () const; virtual InputReader * createlnputReader ( View * ) { return NULL; }
virtual void draw () ; virtual int update () ; virtual int handleChar ( int ch ); virtual int handleKey ( int key, bool pressed virtual int handleKeyboard( const KeyboardState& keys ) = 0; virtual int handleMouse ( const MouseState& mouse ) = О; static MetaClass classinstance; }; Метод createlnputReader предназначен для создания объекта класса InputReader, служащего для чтения ввода пользователя. Метод draw предназначен для построения изображения сцены, ссылка на которую содержится в переменной model. Метод update служит для поддержки возможности анимации сцены и предоставляет возможность всем объектам сцены изменить свое состояние с течением времени. Методы handleKeyboard и handleMouse предназначены для обработки изменений состояния клавиатуры и мыши. Метод handleChar служит для обработки поступивших символов от клавиатуры. Метод handleKey дает возможность реагировать на нажатия и отпускания клавиш, в том числе и на такие, которые не порождают символов, например Fl, Alt и т. п. В результате главный цикл нашего рендерера (обработка ввода и рендеринг сцены) принимает следующий вид: S while ( ! controller -> update () ) controller -> draw (); В нем осуществляется постоянное чтение и обработка ввода (через метод update) и построение изображения (через метод draw). Также этот класс отвечает за расчет скорости рендеринга, измеряемой в числе кадров в секунду (frames per second, fps). Для поддержки этого в массиве lastFrameTimes хранятся значения времени для последних восьми кадров (использование сразу нескольких кадров служит для уменьшения погрешностей, вызванных различными задержками и неточностью таймера). Полученное значение также усредняется со значением с предыдущего кадра для устранения случайных искажений.
О| иж. void Controller :: draw () { if ( (resourcesUploaded ) { Application :: instance -> getResourceManager () -> upload (); resourcesUploaded = true; ) if ( getModel () != NULL && camera != NULL ) getModel () -> render ( *getView (), ‘camera ); console -> draw ( getView () ); float curTime = timer -> getTime (); model -> update ( this, curTime ); numFrames++; if ( numFrames >= 8 ) { fps = 8.Of / (curTime - lastFrameTimes [0]) ; memcpy ( &lastFrameTimes[0], &lastFrameTimes[1] , 7*sizeof ( float ) ); lastFrameTimes [7] = curTime; } else { fps = (float) numFrames / (curTime - lastFrameTimes [numFrames - 1] ); 5 lastFrameTimes [numFrames] = curTime; } font -> print ( view, 10, 30, "fps %4.1f", fps ); } Класс Timer Для правильной анимации и расчета числа кадров в секунду нужен механизм отслеживания времени. Поскольку возможно использование разных механизмов отслеживания времени, то удобно для этой цели также использовать объекты. Интерфейс для данных объектов приводится ниже.
class Timer : public Object { public: Timer ( const char * theName ) : Object ( theName ) { metaClass = &classlnstance; } virtual float getTime () const = 0; // elapsed time since timer's creation virtual void pause () = 0; // pause timer, can be nested virtual void resume () = 0; // resume timer, can be nested static MetaClass classinstance; }; Timer * getTimer ( const char * theName = "" ); // factory method for getting timers Метод getTime возвращает время в секундах с момента создания таймера. Методы pause и resume позволяют приостанавливать и продолжать работу таймера. Описанный класс Timer является интерфейсом, и для создания таймеров служит функция getTimer, возвращающая экземпляр подкласса класса Timer. На компакт-диске применяются два таких подкласса - класс Win-dowsTimer и класс HiPrecTimer. Первый из них использует функцию timeGetTime из библиотеки Windows, второй - применяет функции QueryPerformanceCounter и QueryPerformanceFrequency. Класс Camera Еще одним классом, который нам понадобится, является класс Camera -модель камеры (игрока), помещенной в трехмерную сцену. Камера обладает следующими основными атрибутами - положением, ориентацией, углом обзора (field of view, fov), усеченной пирамидой видимости (viewing frustrum). Фактически камера включает в себя прямоугольную систему координат, область видимости и параметры проектирования и окна, в которое осуществляется вывод изображения.
( Положение камеры будем задавать при помощи вектора pos, а для задания ее ориентации будем использовать 3 ортонормированных вектора -MewDir, upDir и rightDir, задающие направление взгляда вперед, направление вверх и направление вправо соответственно. Для задания области видимости мы будем использовать объект view-Frust ruin. Пользователю предоставляется возможность перемещать камеру (В пространстве, изменять ее ориентацию и другие параметры. Рассмотрим описание этого класса. plass Camera : public Object public: Vector3D pos; Vector3D viewDir; Vector3D upDir; Vector3D rightDir; Matrix3D transf; // camera position // viewing direction (normalized) //up direction (normalized) // right direction (normalized) // camera transform (from world // to camera space) float fov; float zNear; float zFar; int width; int height; float aspect; bool mirrored; // field of view angle // near clipping z-value // far clipping z-value // view width // view height // aspect ratio of camera // whether the camera is mirrored Frustrum viewFrustrum; // vewiwing frustruin Camera ( const char * theName, const Vector3D& p, float yaw, float pitch, float roll, float aFov = 60, float nearZ = O.lf, float farZ = 2000 ); Camera ( const Camera& camera ); Camera (); const Vector3D& getPos () const { return pos; } void setPos ( const Vector3D& newPos ) {
pos = newPos; coinputeMatrix () ; // since clipping planes must //be rebuild } const Vector3D& getViewDir () const { return viewDir; } const Vector3D& getRightDir () const { return rightDir; } const Vector3D& getUpDir () const { return upDir; } float getZNear () const { return zNear; } float getZFar () const { return zFar; } float getAspect () const { return aspect; } const Frustrum& getViewFrustrum () const { return viewFrustrum; } bool inViewingFrustrum ( const Vector3D& v ) const { return viewFrustrum.contains ( v ); }
bool inViewingFrustrum ( const BoundingBox& box ) const { return viewFrustrum.contains ( box ); } // map vector from world space to camera space Vector3D mapFromWorld ( const Vector3D& p ) const { Vector3D tmp ( p -• pos ) ; return Vector3D ( tmp & rightDir, tmp & upDir, tmp & viewDir ); } // map vector from camera space to // world space Vector3D mapToWorld ( const Vector3D& p ) const { return pos + p.x * rightDir + p.y * upDir + + p.z * viewDir; } // map vector to screen space Vector3D mapToScreen ( const Vector3D& p ) const { Vector3D scr ( transf * ( p - pos ) ); scr /= scr.z; //do perspective transform return scr; } bool isMirrored () const { return mirrored; } float getFov () const { return fov; } intgetWidth () const { ' return width; }
intgetHeight () const { return height; } void setEulerAngles ( float float theYaw, float thePitch, theRoll ); void setViewSize ( int theWidth, int theHeight, float theFov ); void setAspect ( float newAspect ); void setFov ( float newFovAngle ) ; void mirror ( const Plane& ); void transform ( const Transform3D& ); void buildHomogeneousMatrix ( float matrix [16] ) const; static MetaClass classInstance; private: void computeMatrix (); // compute vectors, // transform matrix and // build viewing frustrum } ; Методы inViewingFrustrum служат для проверки попадания объекта в область видимости камеры (хотя бы частично). Метод mapToScreen позволяет определить экранные координаты проекции данной точки при использовании камеры. Метод порталов Рассмотрим теперь подробнее реализацию метода порталов. Базовым объектом в нем будет комната с находящимися внутри нее гранями. Комнаты состоят из выпуклых многоугольников и связаны между собой при помощи порталов. Поскольку мы собираемся активно использовать графический ускоритель, то можно отказаться от требования обязательной выпуклости комнат, возникающего в классическом методе порталов - аппаратно-реализованный метод z-буфера все равно правильно обработает все грани внутри каждой комнаты. При этом пропадает необходимость в сильном разбиении исходной сцены для получения выпуклых комнат. Так, комнату, показанную на рис. 7.3, а, можно рассматривать как обычную комнату, в то время как классический метод порталов требует ее разбиения на выпуклые части, как это показано на рис. 7.3, б.
Рис. 7.3 Следует иметь в виду, однако, что при этом для сцены с рис. 7.3 мы теряем загораживающую способность колонн в центре комнаты, но заметно выигрываем в уменьшении общего числа порталов и, следовательно, операций отсечения по ним. Портал представляет собой обычный многоугольник, с которым связано указание на комнату, в которую он ведет. Удобно также добавить в него указание на комнату, в которой он содержится. Поэтому класс Portal можно унаследовать от класса Polygon3D, добавив необходимые поля и методы доступа к ним. В связи с этим нет необходимости отдельно хранить обычные грани и порталы, все их можно поместить в общий массив (объект класса Array). Ниже приводится описание этого класса. Э class Portal : public Polygon3D { public: SubScene * srcScene; SubScene * dstScene; Portal ( const Portals thePortal ) : Polygon3D ( thePortal ) { srcScene = thePortal.srcSene; dstScene = thePortal.dstScene; flags = thePortal.flags; } Portal ( const char * theName, int polySize, SubScene * si = NULL, SubScene * s2 = NULL ) : Polygon3D ( theName, polySize )
{ srcSscene = si; dstScene = s2; setFlag ( PF_PORTAL ); } virtual bool isOk () const { return srcScene != NULL && dstScene != NULL && Polygon3D :: isOk (); } SubScene * getSourceScene () const { return srcScene; } SubScene * getDestScene () const { return dstScene; } void setSourceScene ( SubScene * scene ) { srcScene = scene; } void setDestScene ( SubScene * scene ) { dstScene = scene; } SubScene * getAdjacentSubScene ( const SubScene * scene ) const { return srcScene == scene ? dstScene : srcScene; } static MetaClass classinstance; } ; Нам также понадобится класс, соответствующий отдельной комнате. Экземпляры данного класса не только должны хранить списки граней и порталов, но и уметь строить изображение, видимое в комнате через заданный портал при помощи заданной камеры. Удобно каждому такому объекту добавить ограничивающее тело.
Ниже приводится описание класса SubScene, реализующего отдельную комнату. Е class SubScene : public Object { protected: Array polys; BoundingBox boundingBox; public: SubScene ( const char * theName ) : Object ( theName ), polys ( "Polys" ) { metaClass = &classlnstance; } virtual bool isOk () const { return polys.isOk (); } virtual int init 0 ; virtual void render ( View& view, const Camera&, const Frustrum& ) const; virtual void renderPoly ( View& view, const Camera&, Polygon3D *, Polygon3D&, const Frustrum& ) const; virtual int contains ( const Vector3D& pos ) i const; virtual void update ( Controller *, float systemTime ); void void void void addPoly ( Polygon3D * poly ); addobject ( Visualobject * object ); setFog ( Fog * theFog ); setSky ( Sky * theSky ); const Array& getPolys () const { return polys; } const BoundingBox& getBoundingBox () const { return boundingBox; }
static MetaClass classinstance; protected: void buildFrustrum ( const Vector3D&, const Polygon3D&, FrustrumS, Transform3D * ) const; } ; Главным методом этого класса является метод render, который осуществляет рендеринг комнаты по заданной камере и области отсечения. При этом после вывода всех граней, видимых внутри данной комнаты, он вызывает метод render для комнат, видимых из данной через ее порталы (с учетом области видимости). Тогда алгоритм, реализующий данный подход, можно представить при помощи следующего фрагмента псевдокода: I» view.lock () ; view.apply ( camera ) for p in polys: if not p.isFrontFacing ( camera.pos ): continue if not viewFrustrum.contains ( camera.pos ): continue if p.isPortal (): visPoly = p.clipBy ( viewFrustrum ) newFrustrum = buildFrustrum ( camera.pos, visPoly ) p.adjacentScene.render ( view, camera, newFrustrum ) view.draw ( p ) view.unlock () Вызовы методов lock и apply подготавливают объект view к выводу в него и задают преобразование, соответствующее камере. Далее для каждой из граней по очереди проверяется, является ли она лицевой и лежит ли она (хотя бы частично) в области видимости, задаваемой переменной viewFrustrum. Если хотя бы одно из этих условий не выполнено, то данная грань пропускается как заведомо невидимая. Если грань удовлетворяет обоим условиям и не является порталом, то это обычная грань и она просто выводится. Если же соответствующая грань является порталом, то необходимо произвести рендеринг комнаты, видимой через данный портал. Для этого сперва определяется видимая часть портала
(в качестве ее выступает пересечение портала с областью видимости) и по этой части строится новая область видимости newFrustrum. После этого по данной области у комнаты, в которую ведет данный портал, вызывается метод render для построения того, что видно через данный портал. Рассмотрим подробнее, как происходит рендеринг сцены (рис. 7.4). На рис. 7.4, а изображена область видимости, соответствующая порталу Pi. Обратите внимание, что этот портал целиком лежит в области видимости, поэтому видимой частью портала является весь исходный портал. В область видимости, соответствующую порталу fj, частично попадают два портала - Рг, ведущий в комнату S2, и портал Р3, ведущий в комнату S4. Поэтому при обработке портала Рг будет построена область видимости, соответствующая части комнаты S2, видимой из исходной комнаты So. Также строится область видимости, соответствующая видимой части портала Р}. Рассмотрим, каким образом можно производить отсечение портала (многоугольника) по области видимости. Существует два способа достижения этого: отсечение можно производить в картинной плоскости (на экране) после преобразования в систему координат наблюдателя, проектирования и перевода в систему координат окна или же это можно делать сразу в трехмерном пространстве до всех преобразований. В нашем случае метод, основанный на отсечении сразу в трехмерном пространстве, является явно предпочтительнее, поскольку мы планируем передать все преобразования и проектирование библиотеке OpenGL и делать это второй раз самим было бы неэффективно. Еще одна причина заключается в том, что обычно довольно большое число граней в соседних
комнатах не видно через портал и выполнять все преобразования над ними, чтобы установить их невидимость, является слишком расточительным. Отсечение сразу в трехмерном пространстве позволит быстро отбросить все такие грани. Для дальнейшего ускорения отсечения граней можно организовать их в иерархическую структуру. При этом возникает необходимость построения усеченной пирамиды по положению наблюдателя и видимой части портала. Это делает метод build-Frustrum, исходный текст которого приводится ниже. Обратите внимание на необходимость добавления в качестве ближней плоскости отсечения плоскости самого портала. S void SubScene :: buildFrustrum ( const Vector3D& pos, const Polygon3DS, clipPoly, FrustrumSi frustrum ) const { int numvertices = clipPoly.getNumVertices (); Vector3D newPos ( pos ); frustrum.set ( newPos, numvertices, poly.getVertices () ); // near clipping plane Plane clipPlane ( *poly.getPlane () ); // flip if camera is in positive halfspace if ( clipPlane.classify ( pos ) != IN_BACK ) clipPlane.flip (); frustrum.setNearPlane ( clipPlane ); } Считая все грани непрозрачными и выпуклыми, мы можем реализовать метод render уже на языке C++ следующим образом: S1 void SubScene : : render ( Views, view, const Cameras, camera, const Frustrums, viewFrustrum ) const { // polygon to keep clipped poly Polygon3D tempPoly ( "tempPoly", MAX_VERTICES ); view.lock (); // prepare view for drawing view.apply ( camera ); // render through portals for ( Array :: Iterator it = polys.getlterator (); ! it. end () , ++it )
Polygon3D * роЗу = (Polygon3D *) it.value (); if ( !poly -> isFrontFacing ( camera.getPos () ) ) continue; // if lies outside frustrum => reject it if ( ! viewFrustrum.contains ( poly •> getBoundingBox () ) ) continue; renderPoly } view, camera, poly, tempPoly, viewFrustrum, ob ) ; view.unl.ock (); // commit drawing Чтобы произвести отсечение, нужен временный многоугольник (tempPoly), который сразу создается (один раз на каждый вызов render для каждой комнаты) для уменьшения затрат на его создание и уничтожение. Процедура вывода как обычных граней, так и порталов вынесена в отдельный метод renderPoly, приводимый ниже. S1 void SubScene :: renderPoly ( View& view, const Camera^ camera, Polygon3D * poly, Polygon3D& tempPoly, const Frustrum& viewFrustrum ) const { if ( poly -> testFlag ( PF_PORTAL ) ) // this is a portal { Frustrum newFrustrum; Camera newCamera ( camera ); Portal * portal = (Portal *) poly; SubScene * adj Scene = portal -> getAdjacentSubScene ( this ); tempPoly = *poly; // copy current poly to temp poly // clip against view frustrum if ( !tempPoly.c3ipByFrustrum ( viewFrustrum ) ) return;
// build frustrum, corresponding to // clipped portal buildFrustrum ( camera.getPos (), tempPoly, newFrustrum ); // render through portal adjScene -> render ( view, newCamera, newFrustrum ); } view.draw ( *poly ); } Данный метод проверяет, не является ли переданный многоугольник порталом (при помощи проверки флага PF_PORTAL), и если да, то строится многоугольник, являющийся видимой частью данного портала. Следующим шагом является построение области видимости по камере и видимой части портала. Далее соседней комнате по данным параметрам передается запрос на рендеринг ее видимой части. После этого осуществляется рендеринг самого многоугольника. Это позволяет строить порталы с полупрозрачной текстурой или маской видимости. Конечно, явный вызов метода renderPoly для каждой грани сцены не очень эффективен с точки зрения быстродействия, но вопросы оптимизации мы оставим до второй части работы. Также нам понадобится объект, описывающий всю сцену целиком. Такой объект (очевидно, что он должен быть унаследован от класса Model) должен содержать в себе список всех комнат, поддерживать поиск комнаты по заданным координатам наблюдателя(заметим, что данная операция не всегда бывает однозначной - при использовании порталов с преобразованиями, о которых речь пойдет далее, возможна ситуация, когда несколько различных комнат соответствуют одной и той же части пространства). Ниже приводится описание такого класса. О| class World : public Model { protected: Array scenes; SubScene * currentSubScene; Vector3D updatesize; Array polys;
public: World ( const char * theName ); -World (); // methods for rendering and getting // potential colliders virtual void render ( Views view, const Cameras: camera ) ; virtual void getColliders ( const BoundingBox& area, Arrays colliders ); virtual void update ( Controller *, float systemTime ); virtual bool pointVisibleFrom ( const Vector3D& from, const Vector3D& to ) const; bool addScene ( SubScene * scene ); Texture * getTexture ( const Strings theName ) const; SubScene * getSubScene ( const Vector3D& pos ) const; SubScene * getSubScene ( const Strings theName ) const { return (SubScene *)scenes.getObjectWithName ( theName ); } static MetaClass classinstance; } ; Вместо явного задания сцены внутри исходного кода на языке C++ гораздо удобнее хранить сцену внутри текстового файла (для таких файлов мы будем далее использовать расширение sc) и читать ее как ресурс. Осталось только разработать формат файла, содержащего описание сцены, и класс, отвечающий за загрузку сцены из этого файла. При задании сцены мы будем использовать координатную систему, изображенную на рис. 7.5. Рис. 7.5 При этом оси Ох и Oz идут в горизонтальной плоскости, а ось Оу направлена вверх. Ниже приводится пример простого описания сцены.
camera position (0, 1, 0 ) angles ( 0, 0, 0 ) subscene nameOO { polygon frontl { texture "..\Textures\Oakqrtrt.jpg" mapping (0 -1.390625 0) 0 (1 0 0) 0 vertex (12 5) vertex (105) vertex (-5 0 5) vertex (-5 2 5) portal front2 { connect name00_2 vertex vertex vertex vertex (3 2 (3 0 (1 0 (1 2 5) , 5) , 5) , 5) , (0,0) (0,0) (0,0) (0,0) polygon front3 { texture "..\Textures\Oakqrtrt.jpg" mapping (0 -1.390625 0) 0 (-1 0 0) 0 vertex (52 5) vertex (5 0 5) vertex (3 0 5) vertex (3 2 5) polygon rightl { texture "..\Textures\Oakqrtrt.jpg" mapping (0 0 -1.390625) 0 (0 -1 0) 0 vertex (5 2 -3) vertex (5 2 -5) vertex (5 0 -5) vertex (5 0 -3)
portal right2 { connect name00_l vertex (52-1), (0,0) vertex (52-3), (0,0) vertex (50-3), (0,0) vertex (50-1), (0,0) } } Как видно, это текстовый файл (следовательно, он может быть создан и изменен в любом текстовом редакторе), имеющий построчную структуру- каждая команда занимает одну строку, пустые строки и пробелы игнорируются. Текст, следующий за символами '#" и до конца строки считается комментарием и также игнорируется. Команда camera задает начальное положение камеры в пространстве и ее ориентацию (при помощи углов Эйлера). В начале идут координаты положения игрока (трехмерный вектор - (х, у, z), далее идут 3 утла Эйлера (yaw, pitch, roll), задающие ориентацию: camera position ( х, у, z ) angles ( yaw, pitch, roll ) Команда subscene служит для задания комнаты - все, что находится между соответствующими фигурными скобками, описывает содержимое комнаты. Сразу после слова subscene следует название комнаты, оно должно быть уникальным в пределах всей сцены. Описания комнат следуют одно за другим, вложенность комнат друг в друга не допускается. Внутри комнаты могут находиться грани и порталы. Рассмотрим сначала описание грани. Оно начинается со слова polygon, за которым следует имя грани, которое должно быть уникальным в пределах комнаты. После этого в фигурных скобках идет описание грани. Грань описывается при помощи команд texture, color и vertex. Команда color задает цвет грани в формате RGB А, каждая компонента цвета принимает значение от 0 до 1. Команда имеет вид color (г, g, b, alpha) Команда texture служит для задания текстуры грани и закона соответствия текстурных координат вершинам грани. Она имеет вид texture "texture-name"
После ключевого слова texture следует имя файла с текстурой в двойных кавычках. Для задания способа вычисления текстурных координат служит команда тар, имеющая следующий вид: map (их иу uz) uOffs (vx vy vz) vOffs После ключевого слова map идет набор из восьми чисел, задающий аффинный закон вычисления текстурных координат. Для точки с координатами (х, у, z) соответствующие текстурные координаты вычисляются по следующим формулам: u = ux*x + uy*y + uz*z + uOffs, v = vx*x + vy*y + vz*z + vOffs. Команда vertex служит для задания очередной вершины многоугольника. Она принимает один из следующих видов: vertex (х у z) vertex (х у z), (u v) vertex (х у z), (и v), (г g Ь а) Эта команда задает координаты вершины, а также текстурные координаты и цвет в качестве необязательных параметров. Координаты текстуры, если они не заданы явно, вычисляются по ранее приведенным формулам. Если цвет не задан, то он полагается равным цвету, задаваемому командой color, или (7, 7, I, I), если эта команда отсутствует. Задание портала осуществляется аналогично заданию грани, но вместо ключевого слова polygon используется слово portal и добавляется еще одна команда - connect. Она задает комнату, в которую ведет портал, и имеет следующий вид: connect subscene-name Для создания объекта класса World по sc-файлу будем использовать объект класса SceneDecoder, который из-за своих размеров в книге не приводится, но полностью содержится на компакт-диске. Когда все основные объекты созданы, то процесс рендеринга сцены и управление перемещением игрока выглядят следующим образом (фактически это просто цикл обработки сообщений): тд for ( ; ; ) { autoreleasePool -> releaseAll ();
if ( PeekMessage ( &msg, NULL, О, О, PM NOREMOVE ) ) { if ( IGetMessage ( &msg, NULL, 0, 0 ) ) return msg.wParam; TranslateMessage ( &msg ) ; DispatchMessage ( &msg ) ; } el se if ( view -> isVisible () ) { view -> lock (); if ( controller -> update () == controllerQuit ) return 0; view -> unlock (); } } Объект класса Controller отвечает за обработку сообщений (от мыши и клавиатуры) и управляет перемещением игрока по сцене в соответствии с этими сообщениями. Для удобства работы основные операции по созданию, уничтожению основных объектов и организации цикла по обработке сообщений можно "завернуть" в класс Application. class Application : public Object ( protected: HINSTANCE hlnstance; // windoze's instance View * view; Model * model; Controller * controller; ResourceManager * resourceManager; public: Application ( const char * theName, const char * args = "" ); -Application () ; ResourceManager * getResourceManager () const
{ return resourceManager; } View * getview () const { return view; } Model * getModel () const { return model; } Controller * getController () const { return controller; } void okBox ( const char * caption, const char * format, ... ) const; virtual bool isOk () const; virtual int run (); static Application * instance; static MetaClass classinstance; Тогда главная функция нашего приложения примет следующий вид: int PASCAL WinMain ( HINSTANCE hCurlnstance, HINSTANCE hPrevInstance, LPSTR cmdLine, int cmdShow ) { Application app ( "Arwen", cmdLine ); if ( app.isOk () ) return app.run (); return 0; } Ниже приводится диаграмма основных классов.
Рис. 7.6
Глава 8. ПИШЕМ ПОРТАЛЬНЫЙ РЕНДЕРЕР (часть II) В этой главе мы рассмотрим работу с полупрозрачными гранями, проверку и обработку столкновений с гранями сцены и добавим к нашему проекту консоль в стиле игры Quake. Работа с полупрозрачными гранями Уже сейчас можно задавать полупрозрачные грани при помощи команды color или явного задания цвета в вершинах со значением а-компоненты, меньшим единицы, например color (1, 1, 1, 0.5). Однако такие грани скорее всего будут нарисованы неверно. Причина заключается в том, что результат вывода полупрозрачных граней (в отличие от непрозрачных) зависит от того, в каком порядке они выводятся. Поэтому обычно сначала выводятся все непрозрачные грани (их можно выводить в любом порядке), после чего осуществляется вывод всех полупрозрачных граней начиная с самой дальней и заканчивая самой ближней (back-to-front). Для определения прозрачности грани добавим метод isTransparent в классы Texture и Polygon3D. Простейший вариант этого метода для класса Texture выглядит следующим образом: Э bool isTransparent () const { return format.bitsPerPixel == 8 || format.bitsPerPixel == 32; } Здесь мы считаем, что если текстура имеет a-канал, то в ней почти наверняка найдется полупрозрачный пиксел. При необходимости можно добавить явную проверку каждого пиксела текстуры, однако это может сильно замедлить загрузку текстуры. Метод isTransparent для грани должен проверить не только прозрачность текстуры, но и цвет как в каждой из вершин, так и глобальный цвет для всей грани.
bool Polygon3D :: isTransparent () const { 1 if ( texture != NULL && texture -> isTransparent () ) return true; if ( colors != NULL ) for ( int i = 0; i < numVertices; i++ ) if ( colors [i].w < l.Of - EPS ) return true; return color.w < l.Of - EPS; } Простейшим способом упорядочения граней является их сортировка (например, по расстоянию от середины грани до наблюдателя вдоль направления вектора взгляда), однако в определенных случаях этот метод может давать неправильные результаты. Здесь мы будем использовать BSP-деревья (см. гл. 2) для упорядочения полупрозрачных граней (и только их). В случае если ориентация и расположение полупрозрачных граней не меняются в процессе просмотра сцены, то использование BSP-деревьев оказывается очень удобным. И даже в тех случаях, когда происходит изменение положения (или ориентации) полупрозрачных граней, поскольку их обычно немного, BSP-дерево по ним может быть быстро перестроено. Из-за небольшого числа полупрозрачных граней (а BSP-дерево строится только по ним) сильного разбиения граней не происходит и сама операция построения дерева занимает лишь незначительное время. Узел BSP-дерева может быть представлен в виде следующего класса: Е class BspNode : public Plane { public: Polygon3D ' * facet; BspNode ’ * front; // subtree in positive halfspace // (front) BspNode i " back; // subtree in negative halfspace // (back) BoundingBox box ; BspNode ( Polygon3D * poly ) : Plane ( *poly -> getPlane () ) { facet = poly; front = NULL;
back = NULL; } bool visitinorder ( BspNodeVisitork ); // front-to-back traversal bool visitPostorder ( BspNodeVisitork ); // back-to-front traversal const BoundingBoxk getBoundingBox () const { return box; } void setBoundingBox ( const BoundingBox& theBox ) { box = theBox; } } ; Данный класс выведен из класса Plane, поскольку каждый узел дерева соответствует плоскости разбиения. Поля front и back являются указателями на поддеревья, лежащие в положительном и отрицательном полупространствах соответственно. Поле facet указывает на грань, использованную для проведения разбиения. Здесь методы visitinorder и visitPostorder служат для обхода дерева в прямом и обратном порядке(/гоЩ-Го-/?ас£ и back-to-front). Для обхода дерева используется паттерн "Посетитель" [12], соответствующий класс посетителя выглядит следующим образом: class BspNodeVisitor { protected: Vector3D pos; public: BspNodeVisitor ( const Vector3D& v) : pos ( v ) {} virtual -BspNodeVisitor () {} const Vector3D& getPos () const { return pos; }
virtual bool wantsNode ( const BspNode * node ) { return true; virtual } ; bool visit ( BspNode * node ) = 0; Метод visit обрабатывает очередной узел дерева и возвращает false для продолжения обхода дерева и true для его прекращения. Обход дерева реализуется аналогично тому, как это представлено в гл. 2, и выглядит следующим образом: S bool BspNode :: visitinorder ( BspNodeVisitork visitor ) { if ( Ivisitor.wantsNode ( this ) ) return true; int cl = classify ( visitor.getPos () ); if ( cl == IN_FRONT ) { if ( front != NULL ) if ( front -> visitinorder ( visitor ) ) return true; if ( visitor.visit ( this ) ) return true; if ( back != NULL ) if ( back -> visitinorder ( visitor ) ) return true; } else { if ( back != NULL ) if ( back -> visitinorder ( visitor ) ) return true; if ( visitor.visit ( this ) ) return true; if ( front != NULL ) if ( front -> visitinorder ( visitor ) ) return true;
return false; } bool BspNode ;; visitPostorder ( BspNodeVisitor& visitor ) { if ( ! visitor.wantsNode ( this ) ) return true; int cl = classify ( visitor.getPos () ); if ( cl == IN_BACK ) { if ( front != NULL ) if ( front -> visitinorder ( visitor ) ) return true; if ( visitor.visit ( this ) ) return true; i f ( back != NULL ) if ( back --> visitinorder ( visitor ) ) return true; } else { if ( back != NULL ) if ( back -> visitinorder ( visitor ) ) return true; if ( visitor.visit ( this ) ) return true; if ( front != NULL ) if ( front -> visitinorder ( visitor ) ) return true; } return false; Для осуществления обхода BSP-дерева в заданном порядке строится экземпляр класса, унаследованного от класса BspVisitor, и у этого объекта вызывается метод visitinorder или visitPostorder для обхода дерева в заданном порядке. Процедура построения BSP-дерева достаточно проста: выбирается одна из граней переданного списка (мы будем выбирать грань, минимизирую
щую число разбиений) и производится разбиение всех граней на два множества - множество граней, лежащих в положительном полупространстве (front), и множество граней, лежащих в отрицательном полупространстве (back). Если грань лежит сразу в обоих полупространствах, то она разбивается на две части, одна из которых лежит в положительном полупространстве, а другая в отрицательном. Для разбиения грани мы будем использовать метод split класса Polygon3D. После того как все грани будут разбиты на два множества, осуществляется построение двух BSP-деревьев (по одному на каждое из построенных множеств). Каждое из построенных деревьев становится соответствующим поддеревом строящегося дерева. Реализация этого алгоритма приводится ниже. СП BspNode * buildBspTree ( Arrayk polys ) { if ( polys.isEmpty () ) return NULL; int index = findOptimalSplitter ( polys ); Polygon3D * splitter = (Polygon3D *) polys.at ( index ); BspNode * root = new BspNode ( splitter ); Polygon3D * frontPoly = NULL; Polygon3D * backPoly = NULL; Array front ( "Front Polys" ); Array back ( "Back Polys" ); BoundingBoxbox; for ( int i = 0; i < polys.getNumltems (); i++ ) box.merge ( ( (Polygon3D *) polys.at ( i ) ) -> getBoundingBox () ); root -> setBoundingBox ( box ); splitter -> retain (); // since it'll be release’d //in next stmt polys.removeAtlndex ( index ) ; while ( polys.getCount () > 0 ) { ’ Polygon3D * poly = (Polygon3D *) polys.at ( 0 > ; poly -> retain ();
polys.removeAtlndex ( 0 ); switch ( poly -> classify ( *splitter -> getPlane () ) ) case IN_PLANE: case IN_FRONT: front.insert ( poly ); break; case IN_BACK: back.insert ( poly ); break; case IN_BOTH: frontPoly = new Polygon3D ( poly -> getName (), MAX-VERTICES ); backPoly = new Polygon3D ( poly -> getName (), MAX-VERTICES ); poly -> split ( *splitter -> getPlane (), *frontPoly, *backPoly ); front.insert ( frontPoly ); back.insert ( backPoly ); frontPoly -> release (); backPoly -> release (); break; poly -> release (); root -> front = (front.getCount () > 0 ? buildBspTree (front) : NULL ); root -> back = (back.getCount () > 0 ? buildBspTree (back) : NULL ); return root; Функция findOptimalSplitter выбирает грань, которая минимизирует число разбиений при использовании ее для проведения разбиений.
int findOptimalSplitter ( const Arrays polys ) { int bestSplitter = 0; int bestNumSplits = polys.getCount (); for ( int i = 0; i < polys.getCount (); i++ ) { Polygon3D * splitter = (Polygon3D *) polys.at ( i ); const Plane * plane = splitter -> getPlane (); int numSplits = 0; for ( int j = 0; j < polys.getCount (); j++ ) { if ( i == j ) continue; Polygon3D * poly = (Polygon3D *) polys.at ( j ); if ( poly -> classify ( *plane ) == IN_BOTH ) numSplits++; } ' if ( numSplits < bestNumSplits ) { bestNumSplits = numSplits; bestSplitter = i; } } return bestSplitter; } Для вывода всех полупрозрачных граней в правильном порядке удобно использовать специальный подкласс класса BspVisitor - класс BspDrawVisi-tor, описание и реализация которого приводится ниже. Е Class BspDrawVisitor : public BspNodeVisitor < protected: View * view; const Camera * camera; const Frustrum * frustrum; const SubScene * subScene; Polygon3D * tempPoly;
public: BspDrawVisitor ( View * theView, const SubScene * theScene, const Camera * theCamera, const Frustrum * theFrustrum, Polygon3D * theTempPoly ) ; virtual bool visit ( BspNode * node ); } ; bool BspDrawVisitor :: visit ( BspNode * node ) { subScene -> renderPoly ( ‘view, ‘camera, node -> facet, *tempPoly, ‘frustrum ); return false; } Таким образом, для вывода всего BSP-дерева в правильном порядке можно использовать следующую конструкцию: Е1 BspDrawVisitorvisitor ( &view, kscene, ^camera, kviewFrustrum, ktempPoly ); root -> visitPostorder ( visitor ); Для правильной обработки полупрозрачных граней мы будем использовать класс AccSubScene, унаследованный от класса SubScene. Экземпляры этого класса содержат BSP-дерево, построенное только по полупрозрачным граням, и используют его для рендеринга всех видимых полупрозрачных граней. Описание этого класса выглядит следующим образом: Э class AccSubScene : public SubScene { private: Array opaqueFaces; // opaque faces are stored here BspNode * root; // transparent are stored // in the bsp tree public: AccSubScene ( const char * theName ) : SubScene ( theName ), opaqueFaces ( "opaque Faces" ) { root = NULL; metaClass = kclasslnstance; }
-AccSubScene () { if ( root != NULL ) deleteBspTree ( root ); } virtual bool isOk () const ( return opaqueFaces.isOk () && SubScene :: isOk (); } virtual intinit (); virtual void render ( View& view, const Camera& camera, const Frustrum& frustrum ) const; virtual bool shouldBeSorted ( const Polygon3D * poly ) const; void collectPolys ( BspNode * node ); static MetaClass classinstance; }; Метод init этого класса строит списки прозрачных и непрозрачных граней и по полупрозрачным граням строит BSP-дерево. int AccSubScene :: init () { SubScene :: init (); // prepare floating info Array transpFaces ( "transparent facets" ); // collect transparent faces for ( Array :: Iterator it = polys.getlterator () ; !it.end(); ++it ) { Polygon3D * poly = (Polygon3D *) it.value (); if ( shouldBeSorted ( poly ) ) transpFaces.insert ( poly ); else opaqueFaces.insert ( poly ); }
'/ / combine them into the bsp-tree root = buildBspTree ( transpFaces ) ; return 1; } Обратите внимание, что массив polys хранит полный список всех граней, прозрачных и непрозрачных. Даже если грань будет разбита при построении дерева, то в массиве polys сохранится неразбитая копия. Метод render этого класса отличается от аналогичного метода класса SubScene только добавлением вывода полупрозрачных граней из BSP-дерева после вывода всех непрозрачных граней и выглядит следующим образом: void AccSubScene :: render ( View& view, const Cameras: camera, const Frustrumi viewFrustrum ) const { Polygon3D tempPoly ( ”tempPoly”, MAX_VERTICES ); view.lock () ; view.apply ( camera ); // render opaque facets for ( Array :: Iterator it = polys.getlterator () ; !it.end(); ++it ) { Polygon3D * poly = (Polygon3D *) it.value (); if ( :poly -> isFrontFacing ( camera.getPos () ) ) continue; // check against view frustrum if ( !viewFrustrum.contains ( poly -> getBoundingBox () ) ) continue; renderPoly ( view, camera, poly, tempPoly, viewFrustrum, ob ); } // now render transparent polygons if ( root != NULL ) // using BspDrawVisitor class
{ BspDrawVisitor vis ( &view, this, &camera, &viewFrustrum, &tempPoly ); root -> visitPostorder ( vis ); view.unlock (); // commit drawing Консоль Еще одной функциональностью, которую мы собираемся добавить этой главе, является консоль, аналогичная встречающейся в игре Quake во многих других играх. Для обеспечения гибкости и расширяемости консоли удобно изменяе-ую функциональность - поддерживаемые команды - инкапсулировать объекты. Каждый такой объект должен уметь выполнить определенную оманду по заданному списку аргументов. Удобно сразу же добавить воз-ожность вывода подсказки по данной команды. Тогда в самой консоли росто хранится список таких команд-объектов. Интерфейс команды может ыть описан при помощи следующего класса: s ConsoleCommand : public Object iublic: ConsoleCommand ( const char * theName ) Object ( theName ) {} virtual void virtual void execute ( const Array& argv, Console * * console ) {} printHelp ( Console * console ) const {} static MetaClass classinstance; Метод execute служит для выполнения команды. На вход он получает race ив из строк-аргументов (введенная пользователем строка разбивается [робелами на слова) и указатель на саму консоль. Метод printHelp служит для вывода помощи по команде. При таком подходе сама консоль содержит список объектов-команд I для каждой введенной пользователем команды ищется соответствующий объект, которому и передается запрос на выполнение команды.
Подобный подход позволяет легко добавлять поддержку новых команд в консоль без необходимости каких-либо изменений в исходном коде самой консоли. В частности, возможно добавление команды прямо на этапе выполнения программы. Тогда сама консоль может быть реализована при помощи следующего класса: class Console { private: : public Object Texture ’ k background; // shaded background float bottom; // coordinate of bottom // of console in 0..1 range Array ’ k text; // text in console Array ’ k history; int curbine; // text top line in console String saveStr; int state; // internal state of console // - active, inactive, // opening or closing String str; // currently typed string Font * font; // font used for drawing // the text float cursorOnTime; // time in millisecs since // cursor was on Set commands; // a set of Consolecommand // obj ects Controller * controller; ResourceManager * resourceManager; Timer * timer; int cursorPos; int linePos; // position of current line // in histoy String textureName; public: Console ( const String& textureName, Controller * theController ); -Console (); // overriden Object methods virtual bool isOk () const; virtual Object * clone () const; void draw ( View * ); // draw the console on top // of screene
int handleChar int ch ) ; // handle character, returns // non-zero if console // is active // handle key, returns non -// zero if console is active int handleKey ( int key, bool pressed ); void addCommand ( ConsoleCommand * command void addstring ( const String& ); void execute ( const Strings str ) ; bool executeHelp ( const Arrays argv ); int getState () const { return state; } Controller * getController () const { return controller; } ' static MetaClass classinstance; } ; Ниже приводится реализация этого метода. Главными методами консоли являются методы handleKey и execute. > Метод handleKey обрабатывает очередной введенный пользователем ^символ, а метод execute выполняет законченную команду, введенную -пользователем. Svoid Console :: execute ( const Strings str ) { Array argv; String buf; history -> insertNoRetain ( new String ( str ) ); curbine++; cursorPos = 0; for ( int i = 0; i < str.getbength (); i++ ) { char ch = str [i];
if ( ch == ' ' || ch == '\t' ) {• if ( buf.getLength () > 0 ) argv.insertNoRetain ( new String ( buf ) ); buf = ""; } else buf += ch; } if ( buf.getLength () > 0 ) argv.insertNoRetain ( new String ( buf ) ); if ( lexecuteHelp ( argv ) ) for ( Set Iterator it = commands.getlterator (); ! it.end (); + + it ) { ConsoleCommand * com = (ConsoleCommand *) it.value (); com -> execute ( argv, this ); } } Для передачи введенных символов контроллер будет просто передавать их консоли. Сама консоль решает, требует ли введенный символ обработки. Для подключения консоли необходимо внести некоторые изменения в класс Controller, а именно добавить переменную console типа Console *, добавить создание и уничтожение консоли, а также передачу сообщений объекту консоли. ' Э intController :: handleChar ( int ch ) { console -> handleChar ( ch ); return controllerContinue; } intController :: handleKey ( int key, bool pressed ) { if ( 'pressed ) //we're interested only in presses return controllerContinue; if ( key == keyEsc || key == keyFlO ) II check for quit command
Глава 8. Пишем портальный рендерер (часть II) return controllerQuit; console --> handleKey ( key, pressed ) ; return controllerContinue; } Также необходимо включить вызов console -> draw (view) для вывода консоли. При создании контроллер автоматически создает консоль и добавляет объекты для поддержки ряда стандартных комнат, приводимых ниже. setvidmode width height fullscreen [on | off] map map-file fov value На рис. 8.1 приводится UML-диаграмма классов, используемых для работы консоли. Рис. 8.1 Обработка столкновений Еще одной возможностью, которую мы рассмотрим в этой главе, будет обработка столкновений игрока с гранями сцены. Рассмотрим теперь, каким образом можно определять и обрабатывать столкновения наблюдателя с гранями сцены. Сначала мы будем считать, что наблюдатель представляет собой единичную сферу, и рассмотрим определение столкновений с одной плоскостью (рис. 8.2, а).
Рис. 8.2 В данном случае на рис. 8.2, в показано неправильное определение точки столкновения, а на рис. 8.2, б - правильное. ,--х Для правильного определения / \ / столкновений единичной сферы ( с плоскостью прибавим к центру сферы / О единичный вектор, направление ко- торого противоположно направлению / нормали к плоскости (-и) (рис. 8.3). Рис. 8.3 В результате мы получим точку на поверхности сферы, которая в результате движения коснется плоскости Psph=O-lt. (8.1) Из этой точки мы начинаем движение в направлении вектора скорости v. Выпустим из точки пересечения на сфере p.[ih луч в направлении вектора скорости v. Этот луч описывается уравнением P = PVh+t-v- (8.2) Для определения момента этого столкновения можно воспользоваться методом intersectByRay класса Plane. Vector3D spherelntersectionPoint =source - plane -> n; plane -> intersectByRay ( spherelntersectionPoint, normalizedVelocity, t ) ; Vector3D pointOnPlane ( spherelntersectionPoint + t * normalizedVelocity );
Однако при этом желательно учесть случай, когда плоскость уже пересекает сферу, т. е. точка касания р h лежит в отрицательном полупространстве относительно плоскости (рис. 8.4). В этом случае мы будем искать точку не в направлении вектора движения, а в направлении, обратном направлению вектора нормали к плоскости. В результате описанного алгоритма мы определяем момент пересечения (параметр i), а также точку на плоскости, в которой произойдет столкновение. Если у нас имеется набор плоскостей, то необходимо проверить на столкновение каждую из них. После этого выбирается ближайшая точка (соответствующая минимальному значению параметра t). Столкновение сферы с многоугольником несколько сложнее (рис. 8.5, а). Для начала определяется пересечение сферы с плоскостью, проходящей через этот многоугольник. Проблема здесь заключается в том, что найденная точка столкновения сферы с плоскостью грани может и не принадлежать многоугольнику (рис. 8.5, б). Рис. 8.5 В случае, когда найденная точка пересечения сферы и плоскости принадлежит многоугольнику, она и является искомой точкой пересечения сферы с многоугольником. В противном случае необходимо найти ближайшую к точке пересечения на сфере, найденной ранее, точку на границе многоугольника (рис. 8.6). Это и будет точка на многоугольнике, которая коснется сферы. После этого из этой точки выпускается луч в направлении, обратном к вектору скорости сферы. Если этот луч пересекает сферу, то пересечение имеет место и точкой пересечения на сфере будет точка ее пересечения с выпущенным лучом.
В противном случае (луч не пересекает сферу) пересечения сферы и многоугольника нет. Момент столкновения определяется параметром t пересечения выпущенного луча со сферой. Рассмотрим теперь обработку столкновений. Обычно при столкновении объекта с гранью происходит скольжение объекта вдоль некоторой плоскости, проходящей через точку пересечения. Мы далее будем считать, что нормалью плоскости скольжения является нормаль к сфере в точке столкновения, что хорошо согласуется с физическим смыслом (рис. 8.7). Таким образом после определения столкновения строится плоскость скольжения и вектор скольжения. После этого движение продолжается в направлении вектора скольжения. Тем самым обработка столкновений носит рекурсивный характер - находится столкновение и, если оно имеет место, определяется вектор скольжения. После этого проверяется столкновение при движении из уже найденной точки столкновения вдоль вектора скольжения. Это может привести к новым столкновениям и скольжениям и т. д. Рассмотрим теперь, как работать с объектами, не представляющими собой единичные сферы. В ряде случаев оказывается вполне оправданным представлять наблюдателя (для целей определения и обработки столкновений) как эллипсоид с осями, параллельными осям координат сцены. Непосредственная модификация описанного выше метода в общем случае эллипсоида довольно сложна, но этот общий случай может быть легко сведен в уже рассмотренному. Для этого достаточно применить преобразование масштабирование как к эллипсоиду, так и к проверяемым граням, в результате которого эллипсоид перейдет в единичную сферу. После этого осуществляется проверка пересечения единичной сферы с масштабированными многоугольниками и полученная точка пересечения масштабируется обратно с систему координат сцены.
Для того чтобы не проверять на столкновение все грани сцены (или текущей комнаты), удобно ввести в модель получение потенциальных объектов столкновения. А именно определяется область пространства, захватываемая объектом при движении (в виде ААВВ) и сцене передается запрос на определение всех объектов, находящихся в этой области. После чего проверка на столкновение осуществляется только с ними. Именно для этого и служит метод getColliders. Несложно добавить в полученный тест обработку силы тяжести. Для этого достаточно лишь на каждом кадре модифицировать вектор скорости объекта при помощи вектора силы тяжести. Описанный метод определения был разработан Паулем Неттле и может быть найден по адресу www.fluidstudios.com/publications.hlml.
Глава 9. ПИШЕМ ПОРТАЛЬНЫЙ РЕНДЕРЕР (часть III) В этой главе мы добавим в наш рендерер поддержку зеркал и так называемых порталов с преобразованиями - порталов, способных из одного места вести в совершенно другое место, зачастую достаточно удаленное от данного портала. Подобные эффекты встречаются в играх Unreal, Serious Sam и ряде других и выглядят весьма впечатляюще, несмотря на то что их идея и реализация довольно просты. В основе всех этих эффектов лежит простая модификация метода порталов -с порталом связывается аффинное преобразование, искажающее траектории лучей, проходящих сквозь него. Одним из самых простых примеров этого является зеркальное отражение относительно плоскости портала (или грани). Рассмотрим процесс отражения подробнее. Пусть задано плоское зеркало М и камера, расположенная в точке С (рис. 9.1). Тогда можно рассматривать зеркало как портал, который отражает всю сцену (т. е. за ним находится отраженная копия сцены) и показывает ее видимую сквозь себя часть. Однако явное отражение всей сцены зачастую является неудобным, поэтому вместо того, чтобы отражать всю сцену, можно отразить только саму камеру (вместе с пирамидой видимости) и построить изображение неотраженной сцены, видимой сквозь зеркало через отраженную камеру С (рис. 9.2). Таким образом, обработка зеркал становится крайне простой - и камера и область видимости, соответствующая камере, отражаются относительно плоскости, проходящей через зеркало. После этого строится изображение, видимое из отраженной камеры через видимую часть зеркала (которая определяет область видимости). Рис. 9.2
Кроме обычного отражения, к камере можно применять практически любое аффинное преобразование. За счет этого легко получить портал, ведущий прямо из середины одной комнаты в совершенно другое место (рис. 9.3). Это достигается простым применением преобразования переноса к камере в процессе рендеринга данного портала. На рис. 9.3 с порталом Р связывается преобразование переноса, переводящее его в портал Р', т. е. фактически осуществляется разрыв пространства - при движении точки в направлении портала Р происходит мгновенный скачок при попадании в этот портал и точка сразу выходит из портала Р'. То же самое происходит и с лучами света. Обратите внимание, что при этом возникает возможность создать несколько различных комнат, занимающих одно место в пространстве, - в одну из них ведет обычный портал, а в остальные можно перейти только через портал с преобразованием. Таким образом, всегда можно определить, куда именно осуществляется переход (но, зная только координаты точки в пространстве, не всегда можно однозначно определить комнату). При рендеринге такого портала строится преобразованная камера и осуществляется рендеринг сквозь портал (зеркало) при помощи преобразованной камеры. Для поддержки подобных эффектов в класс Portal следует добавить ссылку на преобразование (объект класса Transform3D) и методы доступа к нему и его изменения. Также определенные изменения необходимо ввести в метод renderPoly класса SubScene. Основная поддержка заключается в том, что для зеркал и порталов преобразованиями строится преобразованная камера и именно она используется для ренедеринга через портал (зеркало). S void SubScene : : renderPoly ( View& view, const Camera^ camera, Polygon3D * poly, Polygon3D& tempPoly, const Frustrum& viewFrustrum ) const
if ( poly --> testFlag ( PF_PORTAL ) ) // this is a portal { Frustrum newFrustrum; Camera newCamera ( camera ); Portal * portal = (Portal *) poly; SubScene * adj Scene = portal -> getAdjacentSubScene ( this ); tempPoly = *poly; // copy current poly to temp poly // clip against view frustrum if ( !tempPoly.clipByFrustrum ( viewFrustrum ) ) return; // apply transform if ( portal -> getTransform () != NULL ) { newCamera.transform ( *portal -> getTransform () ); tempPoly.transform ( *portal -> getTransform () ); } // build frustrum, corresponding // to clipped portal buildFrustrum ( newCamera.getPos (), tempPoly, newFrustrum ); // render through portal adjScene -> render ( view, newCamera, newFrustrum, post, ob ); view.apply ( camera ); // restore camera } else if ( poly -> testFlag ( PF_MIRROR ) ) { Frustrum newFrustrum; Camera mirroredCamera ( camera ); Transform3Dtr ( Transform3D :: getMirror (*poly -> getPlane ()) ) ; // build mirrored camera mirroredCamera.transform ( tr ); // build corresponding // view frustrum tempPoly = *poly; // copy current poly to temp poly
// clip against view frustrum if ( ! tempPoly.clipByFrustrum ( viewFrustrum ) ) return; // build frustrum, corresponding // to clipped portal buildFrustrum ( mirroredCamera.getPos (), tempPoly, newFrustrum ); // render with mirrored camera if ( mirrorDepth < World :: maxMirrorDepth ) { mirrorDepth++; render ( view, mirroredCamera, newFrustrum, post, ob ) ,-mirrorDepth--; } // restore camera view.apply ( camera ); } // do not draw portals without // texture if ( poly -> getTexture () != NULL || !poly -> testFlag ( PF_PORTAL ) ) view.draw ( *poly ); polysRendered++; } view.draw ( *poly ) ; polysRendered++; } Для построения новой области видимости используется положение преобразованной камеры и преобразованная часть портала (зеркала), отсеченная по текущей области видимости. Обратите внимание на использование переменной mirrorDepth. Она служит для прекращения бесконечной рекурсии, которая может возникнуть при обработке отражений и порталов с преобразованиями. Позволяется только не более заданного числа отражений и порталов с преобразованием.
Однако приведенный код несет в себе определенную проблему - если позади зеркала или портала с преобразованием находятся какие-либо грани, то они могут оказаться видимыми сквозь зеркало (портал), так как в z-буфере они окажутся ближе, чем те грани, которые видны в нем. Как показано на рис. 9.4, объект В оказывается видимым и закрывает собой объект А, чего быть не должно (объект В вообще не должен быть виден для наблюдателя, расположенного в точке О. Рис. 9.4 Для решения данной проблемы достаточно выводить порталы и зеркала, за которыми могут находиться другие грани данной комнаты (их называют плавающими, floating) после вывода всех непрозрачных граней и чистить z-буфер (записывать в него значение, соответствующее максимальной глубине) за ними. При этом для корректной работы с полупрозрачными гранями те полупрозрачные грани, которые лежат за плавающим порталом (зеркалом), должны быть выведены перед ним. Фактически это означает, что плавающие порталы и зеркала должны обрабатываться так же, как и обычные полупрозрачные грани. Тем самым для правильного упорядочение плавающих порталов (зеркал) и полупрозрачных граней можно использовать BSP-дерево. Удобно сразу же при инициализации объекта SubScene пометить все плавающие объекты автоматически. Для этого внесем изменение в метод init класса SubScene. d ч£1. intSubScene :: init () { boundingBox.reset (); for ( Array :: Iterator it = polys.getlterator (); !it.end (); ++it )
{ Polygon3D * poly = (Polygon3D *) it.value (); if ( poly -> testFlag ( PF_PORTAL ) | | poly -> testFlag ( PF_MIRROR ) ) if ( isFloating ( poly ) ) poly -> setFlag ( PF_FLOATING ); boundingBox. acidvertices ( poly -> getVertices (), poly -> getNumVertices () ); } return Object :: init (); } Метод isFloating служит для проверки того, является ли данная грань (или портал) плавающей. Для этого достаточно проверить, есть ли хоть одна грань в данной комнате, лежащая позади данной. Для простоты мы будем классифицировать все порталы с преобразованием как плавающие. м bool SubScene : : isFloating ( Polygon3D * poly ) const { if ( poly -> testFlag ( PF_PORTAL ) ) if ( ((Portal *) poly) -> getTransform () != NULL ) return true; Plane plane ( *poly -> getPlane () ) ; for ( Array :: Iterator it = polys. getlterator (); lit.end (); ++it ) { Polygon3D * p = (Polygon3D *) it.value (); if ( p -> testFlag ( PF_FLOATING ) | | p == poly ) continue; if ( p -> classify ( plane ) == IN_BACK ) return true; } return false; } Для очистки z-буфера по видимой части портала (зеркала) удобно использовать буфер трафарета графического ускорителя - портал (зеркало) выводится с выключенной записью и в буфер кадра и в буфер глубины,
но с изменением значения в буфере трафарета для тех пикселов, которые видны. В результате ни буфер глубины, ни буфер кадра не изменятся, но для всех видимых пикселов портала (зеркала) значение в буфере трафарета изменится. Тогда на следующем шаге выводится грань, спроектированная на дальнюю плоскость с выключенной проверкой глубины, включенной проверкой трафарета и включенной записью в буфер глубины (запись в буфер кадра должна быть также выключена). В результате буфер кадра вообще не изменится, а в буфер глубины будут записаны значения только для тех пикселов исходного портала, которые были бы видны при его выводе. Но записываемые значения глубины будут взяты не из исходного портала, а из проекции грани на дальнюю плоскость, т. е. фактически в них будет выведена +°° , т. е. действительно произойдет избирательная очистка буфера глубины. Буфер трафарета также может использоваться для отсечения граней по порталу, т. е. осуществление этого отсечения полностью перекладывается на графический ускоритель. Порталы тем не менее нужно отсекать явно, чтобы избегать обработки всех граней из смежных комнат (явное отсечение порталов позволит сразу отбрасывать невидимые порталы). Для реализации описанного подхода удобно создать специальный подкласс класса SubScene. Поскольку при рендеринге сцены нам придется часто пробегать через цепочку порталов, то в качестве операции над буфером трафарета удобно использовать увеличение и уменьшение на единицу и сравнение с заданным значением. Удобно сделать это значение static-членом класса. Подобный подход реализован в предлагаемом ниже классе. Н class StencilSubScene : public SubScene { private: Array opaqueFaces; // opaque and floating // faces/mirrors/portals // are stored here BspNode * root; // transparent faces/mirrors // are stored in the bsp tree public: StencilSubScene ( const char * theName ) : SubScene ( theName ), opaqueFaces ( "opaque Faces" ) { root = NULL; metaClass = kclasslnstance; } -StencilSubScene ()
{ if ( root != NULL ) deleteBspTree ( root ); } virtual bool isOk () const { return opaqueFaces . isOk () && SubScene :: isOk (); } virtual int init (); virtual void render ( View& view, const Cameras camera, const Frustrum& frustrum ) const; virtual bool shouldBeSorted ( const Polygon3D * poly ) const; static int curStencilVal; static MetaClass classinstance; protected: struct BspRenderlnfo { View * view; const Camera * camera; const Frustrum * viewFrustrum; Polygon3D * tempPoly; Array * post; } ; struct ObjectSortinfo { Visualobject * object; Float key; friend static int __cdecl objectCompFunc ( const void elemi, const void * elem2 ); void drawPolygon ( const Polygon3D& ) const; void renderTree ( BspNode * node, BspRenderlnfoS info, Objectsortinfo list [], int count ) const;
// draw poly to stencil only, using inc op on // depth and stencil pass // draws only to stencil buffer void incStencil ( const Polygon3D& ) const; // restore stencil by using dec op on stencil // pass. Draws only to stencil buffer void decStencil ( const Polygon3D& ) const; // set depth values to that of given polygon // on stencil pass // modifies only depth and stencil buffers void setDepth ( const Polygon3D& ) const; // clear depth values to max depth in visible // points of poly. Draws only on depth buffer, // does not touch frame buffer, // sets stencil to visible points of poly void clearDepth ( const Cameras, const Polygon3D& ) const; // set default values for drawing polygons void setDefaultStencilOpAndFunc () const; void renderPortal ( Views view, const Cameras camera, Polygon3D * poly, Polygon3DS tempPoly, const FrustrumS viewFrustrum ) const; } ; Метод shouldBeSorted проверяет, должна ли данная грань быть отсортированной при помощи BSP-дерева. Метод incToStencil осуществляет вывод данной грани только в буфер трафарета с применением операции увеличения значения буфера на единицу для видимых пикселов грани. Метод decStencil аналогичен предыдущему методу, только он использует операцию уменьшения значения в буфере трафарета на единицу и служит для восстановления буфера трафарета после обработки портала или зеркала. Метод setDepth служит для записи в буфер глубины значения глубины для всех пикселов данной грани, разрешенных буфером трафарета. Метод clearDepth записывает во все пикселы данной грани, разрешенные буфером трафарета, в буфере глубины значение +«=. Ниже приводятся реализации этих методов.
void StencilSubScene :: setDefaultStencilOpAndFunc () const { // enable stencil test glEnable glStencilMask ( GL_STENCIL_TEST ); ( OxFF ); // we do not modify stencil when rendering // normal polys glStencilOp ( GL_KEEP, GL_KEEP, GL_KEEP ) ; ' // enable parts where stencil equals // to curStencilVal glStencilFunc ( GL_EQUAL, curStencilVal, OxFFFFFFFF ) } void StencilSubScene :: incStencil ( const Polygon3D& poly ) const { // save current state glPushAttrib ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_ENABLE_BIT | GL_STENCIL_BUFFER_BIT ); // setup OpenGL so we write only to stencil // buffer in visible pixels of the poly glDisable ( GL_TEXTURE_2D ); glColorMask ( GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE ); glDepthMask ( GL_FALSE ); // set op to increment on both stencil /I and z pass glStencilOp ( GL_KEEP, GL_KEEP, GL_INCR ); glStencilFunc ( GL_EQUAL, curStencilVal, OxFFFFFFFF ); // now draw the polygon to stencil buffer // incrementing stencil when poly is visible drawPolygon ( poly ); // restore attributes glPopAttrib (); } void StencilSubScene :: decStencil ( const Polygon3D& poly ) const
А. В. Боресков. Графика трехмерной компьютерной игры { // save current state glPushAttrib ( GL_COLOR_BUFFER_BIT | GL__DEPTH_BUFFER_BIT | GL_ENABLE_BIT | GL_STENCIL_BUFFER_BIT ); // setup OpenGL so we write only to stencil // buffer in visible pixels of the poly glDisable ( GL_TEXTURE_2D ); glColorMask ( GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE ); glDepthMask ( GL_FALSE ) // set op to decrement on stencil pass (zpass // or zfail) glStencilOp ( GL_KEEP, GL_DECR, GL_DECR ); glStencilFunc ( GL_EQUAL, curStencilVal, OxFFFFFFFF ); // now draw the polygon to stencil buffer // incrementing stencil when poly is visible drawPolygon ( poly ); // restore attributes glPopAttrib (); void StencilSubScene :: clearDepth ( const Camera& camera, const Polygon3D& poly ) const ( int numvertices = poly.getNumVertices (); Vector3D org ( camera.getPos () ); Vector3D normal ( camera.getViewDir () ); Vector3D point ( org + (camera.getZFar () * 0.99f) * normal ); Plane farPlane ( normal, point ); Vector3D v; // glPushAttrib save current state ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_ENABLE_BIT | GL_STENCIL_BUFFER_BIT ); // // // // now reset depth to max where stencil is equal to curStencilVal by projecting verices onto the far plane, leaving stencil unchanged
glColorMask ( GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE ); glDepthMask ( GL_TRUE ) ; glEnable ( GL_STENCIL_TEST ); glStencilFunc ( GL_EQUAL, curStencilVal, OxFFFFFFFF ); glStencilOp ( GL_KEEP, GL_KEEP, GL_KEEP ); glDepthFunc ( GL_ALWAYS ); glBegin ( GL_POLYGON ); const Vector3D * vertices = poly.getVertices (); for ( int i =0; i < numvertices; i++ ) ( Ray ray ( org, vertices [i] - org ); v = ray.point ( ray.intersect ( farPlane ) ); glVertex3fv ( v ); ) glEnd (); // restore attributes glPopAttrib (); } void StencilSubScene :: setDepth ( const Polygon3D& poly ) const { // save current state glPushAttrib ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_ENABLE_BIT | GL_STENCIL_BUFFER_BIT ); // now reset depth to max where stencil is // equal to curStencilVal by projecting // verices onto the far plane leaving // stencil unchanged glDepthMask ( GL_TRUE ); glColorMask ( GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE ); glEnable ( GL_STENCIL_TEST ); glStencilFunc ( GL_EQUAL, curStencilVal, OxFFFFFFFF ); glStencilOp ( GL_KEEP, GL_KEEP, GL_KEEP ); glDepthFunc ( GL_ALWAYS );
// now draw the polygon to depth buffer // leaving stencil intact drawPolygon ( poly ); // restore attributes glPopAttrib (); } Для задания преобразования в портале добавим новую команду в описание портала - transform. Простейший вид этой команды приводится ниже. transform translate (х, у, z) Эта команда задает портал с перемещением на вектор (х, у, z). Для задания зеркала удобно добавить новый стиль грани - зеркало, style mirror Определенные изменения необходимо внести и в модуль обработки столкновений для осуществления перехода при пересечении портала с преобразованием.
Глава 10. РАБОТА С КАРТАМИ ОСВЕЩЕННОСТИ Одним из весьма существенных недостатков рендерера, построенног в предыдущих главах, является отсутствие какой-либо поддержки освещег ности. Все грани считаются одинаково освещенными. Однако в реальном мире всегда присутствуют источники освещена приводящие к неравномерному освещению граней сцены. Так, на рис. 10. приведена сцена с наличием освещенности, а на рис. 10.2 - без нее. Рис. 10.1 Рис. 10.2
Существует много разных способов поддержки освещенности, одним из простейших является применение так называемой закраски Гуро. При ее использовании значения освещенности явно вычисляются в вершинах граней (с использованием формулы 4.1). После чего полученные значения освещенности билинейно интерполируются вдоль всей грани для получения значений освещенности во всех остальных точках грани. Этот способ очень прост и полностью поддерживается средствами библиотеки OpenGL. В ряде случаев он дает вполне приемлемые результаты. Однако ему свойственны также весьма серьезные недостатки. Одним из них является зависимость освещенности грани от положения наблюдателя. Рассмотрим это явление подробнее. Пусть задана грань ABCD (рис. 10.3), где в скобках указаны значения освещенности для каждой из вершин. Тогда для одних положений наблюдателя растеризация грани будет происходить параллельно АС, а для других - параллельно BD. В первом случае при интерполяции вдоль АС мы получим значение единица и, следовательно, освещенность в точке О также будет равна единице. Во втором случае для получения освещенности в точке О интерполяция будет происходить по отрезку BD и искомое значение освещенности будет тождественно равно нулю вдоль данного отрезка и, следовательно, в самой точке О. Поэтому если наблюдатель двигается вокруг этой грани, то величина освещенности в точке О будет меняться в зависимости от его положения, что неверно.
Другими недостатками данного подхода является некорректная обработка теней и отсутствие бликов внутри граней. Последнее вытекает из того, что при билинейной интерполяции освещенности максимальное значение не может превосходить значения в вершинах. Данные недостатки особенно заметны при работе с большими гранями. Использование граней меньшего размера уменьшает влияние этих недостатков, но за это приходится расплачиваться значительным увеличением как общего числа вершин, так и общего числа граней. Все это ведет к росту затрат на обработку всей сцены. Одно из наиболее простых и изящных решений этой задачи было предложено Джоном Кармаком и использовано в игре Quake. Утверждается, что однажды вечером Кармак (размышлявший над организацией освещенности в игре) обратил свое внимание на пятно света на стене. Тогда и пришла идея, что это пятно (как и всю освещенность) можно представить просто как дополнительную текстуру, накладываемую на грани поверх основной текстуры. Эти текстуры (называемые картами освещенности, lightmaps) заранее, на этапе подготовки сцены строятся для каждой грани. Обычно карты освещенности рассчитываются с заметно меньшим разрешением, чем основные текстуры. Фактически для построения карты освещенности грани (подробнее этот процесс будет рассмотрен далее) на грани выбирается набор точек (соответствующий пикселам текстуры, пикселы карты освещенности обычно называются люмелями, lumels). Далее из каждой из этих точек выпускаются лучи ко всем источникам света и определяется вклад всех видимых источников света. При этом учитываются тени, расстояние до каждого источника, его цвет и т. п. В игре Quake использовались карты освещенности в градациях серого цвета, а в Quake И и Quake III Arena - полноценные 24-битовые цветные текстуры. Рассмотрим программную реализацию карт освещенности. Каждый источник света мы будем представлять в виде экземпляра класса Light, приводимого ниже. Каждый такой источник света будет описываться своим положением в пространстве (pos), цветом (color), интенсивностью (brightness) и радиусом влияния источника radius (вне него вклад источника будет игнорироваться). Для описания влияния расстояния до источника на интенсивность света будем использовать следующий закон: Л<0 = 1 a + bd + c-d2 (Ю.1)
Для учета ориентации грани по отношению к источнику света мы будем использовать закон диффузного освещения: Z = / cos ф у (с7), (10.2) где / - интенсивность источника света; ср - угол между направлением на источник света и направлением нормали в точке. Ниже приводится описание соответствующего класса. <OL class Light : public Visualobject { protected: float brightness; // how bright is light source float radius; // it's effective radius float a; // constant part of inverse // attenuation float b; // linear part of inverse // attenuation float C; // square part of inverse // attenuation public: Light ( const char * theName, const Vector3D& thePos, const Vector4D& theColor, float br, float rad, float constant, float linear, float square ); float getBrightness () const { return brightness; 1 float getRadius () const ( return radius; 1 Vector3D getLightAt ( const Vector3D& point, const Vector3D& normal ) const { Vector3D 1 = pos - point; float dist = 1.length (); if ( dist >= radius ) return Vector3D ( 0, 0, 0 ) ; float cosAngle = (float) fabs ( (1 & normal) / dist );
Vector3D cl ( color.х, color.у, color.z ); return cl * (brightness * cosAngle / (a + dist * (b + + c * dist))); } static MetaClass classinstance; } ; Для задания источника света в хс-файле мы будем использовать следующие команды: light yellow_light { pos ( 0, 0.7, 2 ) color ( 1, 1, 0, 1 ) radius 10 brightness 4 constant 0.5 linear 4 square 0 } Параметры constant, linear и square задают коэффициенты уравнения (10.1). Рассмотрим теперь подробнее использование карт освещенности. Для каждой грани исходной сцены необходимо построить ее карту освещенности - текстуру, содержащую значения освещенности грани в некотором наборе точек. Так как карта освещенности является текстурой (мы будем далее считать ее 24-битовой RGB-текстурой), то возникает вопрос о текстурных координатах для вершин данной грани. Однако в общем случае обычные текстурные координаты (применяемые при выводе основной текстуры) не подходят для использования при выводе карты освещенности. Рассмотрим, например, достаточно большую грань, когда основная текстура накладывается с повторением (т. е. текстурные координаты выходят за пределы единичного квадрата). Если непосредственно использовать эти же текстурные координаты для вывода карты освещенно-' сти, то она также будет выведена с повторением, т. е. возникает ситуация, ' когда разным точкам грани соответствует одна и та же точка на карте осве-1 щенности, чего не должно быть. Существуют различные способы построения текстурных координат для использования при выводе карты освещенности. Мы будем использовать , способ, основанный на преобразовании не приведенных к отрезку [0, 1 ] текстурных координат. Для текстурных координат (и, v) находятся наиболь
шие и наименьшие значения для каждой из компонент итш, vmin, umax, vmax. После этого точке с исходными текстурными координатами (и, v) поставим в соответствиие следующие текстурные координаты: и — Ц~Ц|п!|1 , / = V~V|ni" . (10.3) и — и V — V . tn ах mm inax min Эти текстурные координаты мы и будем использовать в качестве текстурных координат для работы с картой освещенности. Другой способ не требует существования каких-либо текстурных координат заранее - грань просто проектируется на одну из координатных плоскостей (Оху, Oxz или Oyz) и координаты на плоскости подвергаются преобразованию (10.3) для получения текстурных координат. Для определения того, на какую из координатных плоскостей следует осуществлять проектирование, определяется компонента вектора нормали к грани с наибольшим по модулю значением. Она будет определять ось, вдоль которой будет осуществляться проектирование. Остальные две оси определяют плоскость. По найденным значениям unin, vinin, wmax, vmax можно найти размер карты освещенности. Обычно карта освещенности берется меньше исходной текстуры в несколько раз. Мы будем следовать игре Quake и возьмем размер карты освещенности в 16 раз меньше соответствующего текстурного диапазона (не размера текстуры, так как или текстура может повторяться, или может использоваться лишь ее часть). Тогда размеры карты освещенности будут определяться следующими формулами: width = (int)ceil ( texMax.x / 16 ) - - (int)floor ( texMin.x / 16 ) + 1; height = (int)ceil ( texMax.y / 16 ) - - (int(floor ( texMin.y /16 ) + 1; Исходя из вышеизложенного, можно построить класс для представления карт освещенности. S! class Lightmap : public Object { private: Vector2D offs; Vector2D scale; Float matrix [4][4]; // OpenGL texture transform // matrix Texture * map; // the map itself
public : Lightmap ( const char * theName, const vector2D& texMin, const Vector2D& texMax, Texture * txt ); -Lightmap (); // remap normal texture // coordinates into normalized // lightmap coordinates Vector2D remap ( const Vector2D& uv ) const { return (uv - offs) * scale; } Texture * getTexture () const { return map; ) const float * getTextureMatrix () const { return (const float *) matrix; } static MetaClass classinstance; } ; Здесь ^max ^inin 1 V — V max пип у V nun у Метод remap служит для постановки в соответствие стандартным текстурным координатам координат, используемых для работы с картой освещенности. Считая, что карты освещенности уже построены (способ их построения рассматривается ниже), рассмотрим сначала способ их наложения. Вместо вывода карт сразу за выводом каждой грани мы будем строить список видимых граней внутри комнаты, требующих вывода карт освещенности, и в конце обработки всей комнаты будут выводиться карты освещенности. Соответствующий метод приводится ниже.
void SubScene :: drawLightmaps ( View& view, const Array& polyList ) const { glPushAttrib ( GL_COLOR_BUFFER_.BIT | GL_TEXTURE_BIT | GL_DEPTH_BUFFER_BIT ); glDepthFunc ( GL_EQUAL ); glDepthMask ( GL_FALSE ) ; glEnable ( GL_BLEND ); glBlendFunc ( GL_ZERO, GL_SRC_COLOR ); glColor3f (1, 1, 1 ) ; // in case it has other // value from prev calls for ( Array :: Iterator it = polyList.getlterator (); !it.end (); ++it ) { Polygon3D * poly = (Polygon3D *) it.value (); Lightmap * lightmap = poly -> getLightmap (); view.bindTexture ( lightmap -> getTexture () ); view.simpleDraw ( *poly, View :: useLightmap ); } glPopAttrib (); } Поскольку карты освещенности выводятся на видимые грани, то в качестве теста глубины можно использовать равенство (GL_EQUAL), а запись в буфер глубины в этом случае просто не нужна. В качестве способа наложения текстуры обычно используется (GL_ZERO, GL_SRC_COLOR), т. е. способ наложения задается следующей формулой: Hghtmcip' (10.5) Для того чтобы при задании каждой грани не надо было явно задавать имя файла с картой освещенности, удобно определить закон, ставящий в соответствие каждой грани по ее имени и имени соответствующей комнаты имя файла с картой освещенности. Поместим реализацию данного закона в класс World. String World :: getLightmapNameForPoly ( const Polygon3D * poly ) const { String sceneName;
if ( poly -> getOwner () != NULL ) sceneName = poly -> getOwner () -> getName (); return lightmapDir + " /" + sceneName + + + poly -> getName () + ".tga"; } Как видно из приведенного кода, мы считаем, что все карты освещенности располагаются в специальном каталоге, например 'lighmaps'. Тогда на этапе загрузки сцены достаточно проверить для каждой грани, существует ли файл с соответствующей картой освещенности по имени, и при необходимости загрузить его и связать с данной гранью. Рассмотрим теперь сам процесс построения карт освещенности. Первым шагом является определение закона освещенности точки. Мы будем использовать следующий закон: /(/’) = Cz. + S< 1,(p)-sXp)- <10-6) I Как видно из формулы (10.6), за освещенность точки р мы будем принимать сумму освещенности точки от каждого источника света с учетом затененности источника (величина т, (р) = 1 тогда и только тогда, когда данный источник свет виден из точки р, в противном случае эта величина равна нулю) и "фоновой" освещенности 1атЬ. Фоновая освещенность гарантирует, что даже в отсутствие близко расположенных источников света освещенность не будет меньше заданной. Сам алгоритм построения карт освещенности достаточно прост - сначала по формуле (10.4) определяются размеры карты, после чего вводится сетка точек на карте, являющаяся разбиением единичного квадрата текстурных координат в соответствии с размерами карты освещенности. Далее для каждой точки сетки определяется точка на грани, соответствующая данным текстурным координатам, и из нее трассируются лучи ко всем источникам света. Если источник света виден, то при помощи функции getLighhtAt определяется его вклад в освещенность данной точки. После того как полная освещенность точки (с учетом фоновой) будет найдена, она обрезается по отрезку [0,1] и записывается в текстуру. После обработки всех точек соответствующая текстура записывается в файл. S bool World : : buildLightmapForPoly ( const Polygon3D * poly ) String lightmapName ( getLightmapNameForPoly ( poly ) ) ;
(*sysLog) « "Building lightmap " « lightmapName « logEndl; if ( poly -> getMapping () == NULL || poly -> getTexture () == NULL ) return true; if ( poly -> testFlag ( PF_PORTAL ) || poly -> testFlag ( PF_MIRROR ) ) return true; int texwidth = poly -> getTexture () -> getwidth (); int texHeight = poly -> getTexture () -> getHeight (); // compute range of texture coordinates // for this poly Vector2D texMin; Vector2D texMax; poly -> getTextureExtent ( texMin, texMax ); // find middle point Vector2D mid ( 0.5f * (texMin + texMax ) ); texMin.x *= texwidth; texMin.у *= texHeight; texMax.x *= texwidth; texMax.у *= texHeight; // get size of lightmap intwidth = (int)ceil (texMax.x/STEP) - (int)floor (texMin.x/STEP) + 1; intheight = (int)ceil (texMax.y/STEP) - (int)floor (texMin.y/STEP) + 1; // create texture object to hold lightmap // with 24 bit format PixelFormat rgbFormat ( OxFF, OxFFOO, OxFFOOOO ); Texture * lightmap = new Texture ( lightmapName, width, height, rgbFormat ); // create buffer to encode pixel data long * buf = new long [width]; for ( int i = 0; i < height; i++ ) { for ( int j =0; j < width; j++ )
Vector2D tex; Vector3D sample; Vector3D color ( ambient ); tex.x = (texMin.x + j * STEP) / texWidth; tex.у = (texMin.y + i * STEP) / texHeight; // check whether we can nudge sample // point to lay within poly if ( buildSampleOnPoly ( poly, tex, mid, sample ) ) { // now sample is our sample point //on poly, accumulate light on it for ( Array :: Iterator it = lights.getlterator (); ! it.end () ; + + it ) { Light * light = (Light *) it.value (); if ( poly -> isFrontFacing (light -> getPos()) ) if ( pointVisibleFrom (sample, light->getPos())) color += light -> getLightAt ( sample, poly -> getNormal () ); } // clamp each component to [0,1] color.clamp ( 0, 1 ); // now we have our light value in this // point write it to buffer buf [j] = rgbToInt ( (int)(255*color.z), (int)(255*color.y), (int)(255*color.x) ); // now put buf as a line in lightmap texture lightmap -> writeLine ( height - 1 - i, buf ); ) // write lightmap to file int size = width * height * 3; MutableData data ( "lightmap", size + 1024 ); TgaEncoder encoder;
encoder.encode ( lightmap, &data ); data. saveToFile ( lightmapName ); // free allocated resources delete buf; delete lightmap; return true; Для определения точки на грани, соответствующей данному люмелю, можно использовать метод иптар класса Mapping, представляющий собой просто обращение метода тар и проекцию полученной точки на плоскость грани. Однако данный подход несет в себе определенную проблему - во многих случаях текстура будет содержать люме-ли, которым не соответствует ни одной точки на грани (рис. 10.4). Так, соответствующая точке А точка на плоскости просто не принадлежит грани и приведенная реализация запишет в соответствующий люмель нулевое значение. Поскольку при выводе карты освещенности обычно применяется билинейная интерполяция (в связи с ее небольшим размером), то значения освещенности для точек, лежащих рядом с границей грани, окажется заметно темнее. Это связано с тем, что как минимум одно значение, используемое при интерполяции, соответствует точке, прообраз которой лежит вне грани, - для таких точек значением люмеля является черный цвет (0, 0, 0). Обратите внимание, что недостаточно просто в этом случае использовать значение фоновой освещенности 11тЬ, поскольку грань может быть достаточно ярко освещена и освещенность вблизи границы все равно уменьшится. Один из достаточно простых способов борьбы с этим явлением заключается в том, что в случае, когда построенная точка на плоскости лежит вне грани, она просто слегка "сдвигается" по направлению к грани (достаточно просто сдвигать по направлению в центру грани в ,силу ее выпуклости). Тогда, если точка лежит достаточно близко к границе (и поэтому может влиять на значение освещенности), небольшое ее "шевеление" переводит ее на грань и, следовательно, получается осмысленное значение освещенности. Данный подход взят из игры Quake II и его реализация выглядит следующим образом:
bool World : : buildSampleOnPoly ( const Polygon3D * poly, const Vector2D& srcTex, const Vector2D& mid, Vector3D& sample ) const float xStep = (float )HALF_STEP/ (float) poly->getTexture()->getWidth (); float yStep = (float)HALF„STEP/ (float) poly->getTexture()->getHeight(); Vector2D tex ( srcTex ); for ( int step = 0; step < NUDGE_STEPS; step++ ) { sample = poly -> mapTextureToWorld ( tex ) ; if ( poly -> contains ( sample ) ) return true; // now try to "nudge" point towards polygon if ( step & 1 ) { if ( tex.x > mid.x ) { tex.x -= xStep; if ( tex.x < mid.x ) tex.x = mid.x; } else { tex.x += xStep; if ( tex.x > mid.x ) tex.x = mid.x; } } else { if ( tex.у > mid.у ) { tex.у -= yStep; if ( tex.у < mid.у ) tex.у = mid.y; }
el se { tex.у += yStep; if ( tex.у > mid.у ) tex.у = mid.у; } } } return false; } Тогда метод для построения всех карт освещенности можно записать следующим образом: а bool World :: buildAllLightmaps () { if ( fcreateDir ( lightmapDir ) ) return false; for ( Array :: Iterator sclt = scenes.getlterator (); !sclt.end (); ++sclt ) ( SubScene * scene = (SubScene *) sclt.value (); for ( Array :: Iterator it = scene -> getPolys ().getlterator (); !it.end (); ++it ) { Polygon3D * poly = (Polygon3D *) it.value (); if ( !buildbightmapForPoly ( poly ) ) return false; } ) return true; } Чтобы не создавать отдельную утилиту для построения карт освещенности по сцене, можно просто добавить эту функциональность к основному модулю. Тогда параметр командной строки '-lightmaps' означает, что нужно только построить карты освещенности для сцены. Хотя данный подход для серьезных проектов вряд ли подходит, в данном случае он оказывается весьма удобным.
Одним из возможных направлений развития этого подхода является учет вторичного освещения - света, падающего на грань не непосредственно от источника, а рассеянного другой гранью. Тогда после вычисления первичной освещенности всех граней можно для каждого из люмелей карты освещенности выпустить из соответствующих точек на грани лучи ко всем точкам на других гранях, также соответствующим люмелям (но уже других карт освещенности). Подобная операция может повторяться несколько раз для достижения более высокой точности. Это напоминает метод излуча-тельности (radiosity) для вычисления глобальной освещенности сцены, однако это сопряжено с большими вычислительными затратами. Еще одно из направлений развития может заключаться в проверке прозрачности пиксела, если луч, выпущенный к источнику света, натолкнулся на грань. Это позволяет строить корректные тени для полупрозрачных (возможно, цветных) граней. Обратите внимание, что если для источника света задать отрицательное значение цвета, например (-0.5, -0.5, -0.5), то мы получим источник "темноты" (darklight), который "освещает темнотой" все вокруг.
Глава 11. ПИШЕМ РЕНДЕРЕР УРОВНЕЙ QUAKE II В этой главе мы рассмотрим весьма распространенный подход, основанный на использовании комбинации BSP-деревьев и множеств потенциальновидимых граней. (PVS). Практически все игры серии Quake, а также многие другие, успешно используют этот подход. Мы рассмотрим его на примере игры Quake 11. В основе этого подхода лежит разбиение всей сцены на набор выпуклых многогранников при помощи листового BSP-дерева. При этом через каждую грань сцены проводится разбивающая плоскость. Листьями дерева будут выпуклые многогранники, ограниченные набором плоскостей. Часть из этих плоскостей проходит через грани сцены (или их части), ограничивающие этот многогранник, остальные - через порталы, связывающие этот многогранник с другими многогранниками (подробнее см. гл. 2). Таким образом, вся сцена автоматически разбивается на выпуклые многогранники и соединяющие их порталы. Однако для типичных сцен получается очень большое число порталов (их число оказывается сравнимым с общим количеством граней в сцене), что делает применение классического метода порталов нецелесообразным из-за очень больших расходов на обработку каждого из получившихся порталов. Вместо этого используется другой подход - на основе полученных порталов для каждого листа дерева (выпуклого многогранника) определяется, какие другие листья этого дерева могут быть видны через порталы. При этом рассматриваются все, что можно увидеть из данного листа, т. е. если существует цепочка порталов, через которую из данного листа (для какого-то определенного положения и ориентации наблюдателя) можно увидеть другой лист, то этот лист считается потенциально видимым. Построение подобных множеств видимости (Potentially Visible Set, PVS) является очень трудоемкой операцией, но она выполняется всего один раз на этапе подготовки уровня разработчиком, после чего построенные множества видимости хранятся вместе со сценой. Использование BSP-деревьев и построенных множеств для рендеринга сцены довольно просто - сначала при помощи BSP-дерева определяется лист дерева, в котором сейчас находится камера (наблюдатель) (фактически это просто спуск по дереву, где в каждом внутреннем узле мы переходим
в лист дерева, содержащий положение камеры), а затем находится построенное множество видимости, т. е. множество всех листьев дерева, видимых из данного. Те из листьев этого множества, которые лежат в области видимости, считаются видимыми, и все их лицевые грани выводятся. Ясно, что при использовании этой схемы, кроме действительно видимых граней, будет выведено небольшое количество невидимых граней. Но поскольку их число невелико и графический ускоритель, используя метод z-буфера, точно определит точную видимость в сцене, то эти расходы оказываются приемлемыми. Поскольку число листьев в BSP-дереве может быть довольно большим, в игре Quake 11 множество всех листьев группируется на так называемые кластеры и для каждого такого кластера хранится список всех кластеров, видимых (хотя бы частично) из данного. Подобный подход позволяет сэкономить место на хранении списков видимости. Все данные для игры (уровни, модели, текстуры, звуки и т. и.) хранятся в одном файле с расширением рак. Это составной файл, содержащий в себе множество отдельных файлов с данными. Для поддержки доступа к данным внутри этого файла находится каталог, содержащий список всех файлов с данными внутри него и ссылки на положение каждого из файлов внутри раЛ-файла. Работа с таким составным файлом может быть легко реализована в терминах класса ResourceSource. На компакт-диске приводится класс PakFileSystem, служащий для доступа к отдельным файлам внутри рак-файла. Описание отдельной сцены (уровня щ-ры) содержится внутри файла с расширением bsp. Это тоже составной файл, состоящий из заголовка и набора блоков (lumps). struct { long long } ; Quake2BspEntry offset; size; // // // // entry in .bsp file directory offset file sizeof from the start of lump in bytes . bsp struct Quake2BspHeader // header of the .bsp file { unsigned char magic [4] ; // signature ("IBSP") long version; Quake2BspEntry dir [19]; };
Первые 4 байта bsp-файла содержат подпись файла ("IBSP"), затем идет номер версии (здесь рассматривается версия 38). Далее идет таблица блоков- для каждого из 19 блоков идет запись Quake2BspEntry, содержащая информацию о размере и местоположении соответствующего блока внутри bsp-файла. Ниже приводится полная таблица блоков (табл. 11.1). Таблица 11.1 Индекс блока Название Описание 0 Entities Текстовые данные, описывающие начальное положение и ориентацию игрока, положение противников и т. II. 1 Planes Массив плоскостей 2 Vertices Массив вершин 3 Visibility Сжатые PVS 4 Nodes Массив внутренних узлов BSP-дерева 5 Texture information Информация о текстурировании граней 6 Faces Массив вершин 7 Lightmaps Карты освещенности 8 Leaves Массив листьев BSP-дерева 9 Leaf Face Table Индексы листьев для каждого листа BSP-дерева 10 Leaf Brush Table И Edges Ребра граней 12 Face Edge Table Массив индексов ребер для каждой из граней 13 Models(Hulls) Набор моделей уровня (BSP-деревья, задающие уровень) 14 Brushes 15 Brush Sides 16 Pop 17 Areas 18 Area Portals Большинство блоков представляет собой массив одинаковых структур фиксированного размера. Для них количество элементов в блоке можно определить как частное размера блока и размера одной структуры. Блок сущностей (entities lump). Представляет собой набор строк, описывающих уровень. Ниже приводится фрагмент таких данных.
( "nextmap" "base2" "sky" "unitl_" "message" "Outer Base" "classname" "worldspawn" "sounds" "9" } { "origin" "32 -224 24" ”classname" " info_player_coop" "angle" "90" } { "origin" "168 -224 24" "classname" "info player coop" "angle" "90" < "origin" "96 -224 24" "angle" "90" "classname" "info player coop" } { "angle" "0" "classname" "info player coop" "targetname" "base2" "origin" "-1592 1528 128" Блок вершин (vertex lump). Представляет собой массив всех вершин сцены. Каждая вершина описывается тремя float-числами, представляющими собой (х, у, z)-координаты. Обратите внимание, что в игре Quake II ось Oz смотрит вверх (рис. 11.1). Блок ребер (edge lump) содержит все ребра сцены. Каждое ребро описывается двумя 16-битовыми беззнаковыми целочисленными значениями -индексами начальной и конечной вершин.
struct Quake2Edge // edge (reside in edges lump) { unsigned short unsigned short }; firstEdge; lastEdge; Блок граней (face lump) служит для описания всех граней сцены и представляет собой массив следующих структур: тд struct Quake2Face // Quake 2 polygon (face) { unsigned short plane; // plane the face is in unsigned short planeSide; // 0 if plane normal // coinsides with facet // normal long firstEdge; // index of 1st of facet // in the face edges table unsigned short numEdges; // number of edges in // the face edges table unsigned short texinfo; // index of texinfo in // the text info array unsigned char lightmapStyles [4]; long lightmapOffset; // offset of the lightmap in the lightmap lump }; Здесь величина plane является индексом плоскости, проходящей через данную грань. Поскольку одна плоскость может проходить сразу через несколько граней, имеющих различные направления вектора нормали, то ненулевое значение величины planeSide означает, что нормаль к грани противоположна по направлению нормали к соответствующей плоскости. Величина firstEdge является индексом первого ребра грани в массиве ребер граней, а величина numEdges содержит число последовательных индексов ребер в блоке ребер граней. Величина texinfo является индексом в массив текстурной информации граней. Величина HghtinapStyles хранит битовые флаги различных стилей освещения. Для каждой грани в блоке карт освещения хранится соответствующая карта освещения, и смещение этой карты находится в поле lightinapOjfset. Блок ребер граней (face edge lump) хранит индексы (в виде 32-битовых целых) ребер для каждой грани. Поскольку одно и то же ребро обычно принадлежит сразу двум граням и имеет для каждой из них проти
воположное направление, то для обозначения того, что ребро должно быть пройдено в обратном направлении, используется отрицательное значение индекса. Блок плоскостей (plane lump) - это массив из структур, описывающих все разбивающие плоскости в BSP-дереве. Каждая такая плоскость описывается следующей структурой: struct Quake2BspPlane // // Quake 2 planes (stored in the planes lump) { Vector3D normal; // plane normal (A,B,C) Float dist; // signed distance along normal // (D in the plane // equation Ax+By+Cz-D=0) long type; // one of the PLANE NTYPE_* }; // constants Уравнение каждой плоскости имеет вид Ах + By + Cz - D = 0. (11-1) Блок внутренних узлов (node lump) - это массив структур, служащих для описания внутренних узлов BSP-дерева. При этом нулевому элементу массива соответствует корень дерева. Каждый внутренний узел дерева представлен следующей структурой: struct Quake2BspNode // internal Bsp tree node { long plane; // index of plane that makes long frontchild; // this node // index of front child node long backChild; // index of back child node short mins [ 3 ] ; // short-based min value of bbox short maxs [3]; // short-based max value // of bbox unsigned short firstFace; // index of the last face //of the node unsigned short numFaces; // number of faces }; Здесь plane - это индекс разбивающей плоскости в массиве плоскостей. Величины frontChild и backChild представляют собой индексы переднего и заднего поддеревьев (узлов) данного узла. Отрицательное значение
соответствующей величины означает, что ссылка идет на лист дерева и номер листа определяется как ~(ии1ех+Г), так что первое отрицательное значение ссылается на нулевой лист. Далее идет ограничивающее тело (ААВВ) для данного узла, причем для задания координат используются 16-битовые целочисленные величины. После этого идут номер первой грани (firstFace) и количество граней (numFaces} в массиве граней. Блок листьев (leaf lump) - содержит описание всех листьев дерева в виде массива следующих структур: р-1 kOL1 struct Quake2BspLeaf // bsp node leaf (stored in // the leaves lump) { long brushOr; // OR'ed brushes unsigned short cluster; // -1 (OxFFFF) if unsigned short short // area; mins [3 ] ; // no visibility information bbox min value as shorts short maxs [ 3 ] ; // bbox max values as shorts unsigned short firstLeafFace; // index of 1st leaf unsigned short numLeafFaces; // face in the leaf // face table // # of leaf faces unsigned short unsigned short firstLeafBrush; numLeafBrushes; }; Список всех граней, содержащихся в данном листе, содержится в массиве граней листьев (face leaf lump} начиная с firstLeafFace и содержит пит-LeafFaces значений. Поле cluster содержит номер кластера, которому принадлежит данный лист. Блок граней листьев (face leaf lump). Для доступа к граням из листьев дерева в данном блоке содержатся индексы всех граней для каждого листа. Этот массив состоит из 16-битовых беззнаковых значений. Блок информации по текстурированию (texture information lump) содержит информацию по текстурированию для каждой из граней сцены в виде массива следующих структур: кЛ. struct Quake2TexInfo // texture coordinates // generation data Vector3D uAxis; Float uOffset;
Vector3D vAxis; Float vOffset; unsigned long flags; // miptex flags and // overrides unsigned long value; // light emission and etc char texName [32]; // texture name without // extension long nextTexture; // next texture in animation // chain or -1 Текстурные координаты для каждой из вершин каждой грани определяются посредством следующих формул: и-[p,uAxis} + uOffset, v = (p,vAxis) + vOffset. (11.2) Поле texName содержит имя текстуры с путем, но без расширения. Поэтому к этому имени нужно прибавить расширения ".wal". Блок видимости (visibility lump) служит для хранения информации о видимости (PVS). В начале блока идет беззнаковое 32-битовое целое значение, являющееся числом кластеров в сцене. Далее идет массив структур Quake2PvsEntry, по одной для каждого кластера. Далее идут в сжатом виде списки видимых кластеров. jgi struct Quake2PvsEntry // directory entry of pvs lump { long pvsOffs; long phsOffs; } ; Поле pvsOffs содержит смещение от начала блока до сжатой информации о видимости для данного кластера. Список видимых кластеров представляется как битовый массив, по 1 биту на каждый кластер. Для уменьшения занимаемой памяти этот массив кодируется с применением так называемого RLE (Run-Length Encoding), позволяющего компактно хранить массивы, содержащие большое количество нулевых битов. Подробнее раскодирование этой информации мы рассмотрим несколько позже. Блок карт освещенности (lightmap lump) хранит карты освещенности для каждой грани сцены. Каждая карта освещенности представляет собой массив из 24-битовый структур (в виде RGB-значений, по 1 байту на каждую компоненту) по одному значению на каждый пиксел. Размер карты освещенности не хранится и вычисляется по текстурным координатам грани (см. гл. 10).
Блок моделей (models(hulls) lump) хранит в себе массив следующих структур, каждая из которых описывает часть уровня, представленную отдельным BSP-деревом. О| struct Quake2Hull // bsp hull { Vector3D mins; // hull bounding box Vector3D maxs; Vector3D origin; long headNode; long firstFace; long numFaces; }; Здесь mins и maxs задают ограничивающее тело, описанное вокруг всей модели, headNode - номер узла, соответствующего корню соответствующего BSP-дерева (узлы всех деревьев хранятся в одном массиве). При этом основная геометрия уровня задается первой такой структурой, а следующие служат для задания вспомогательных структур. Для загрузки ftsp-файла и его разбора мы будем использовать класс BspFile. S’ class BspFile { public: Quake2BspHeader * hdr; Vector3D * vertices; int numVertices; Quake2Edge * edges; int numEdges; Quake2Face * faces; int numFaces; unsigned long * faceEdges; // indices of edges // for any face Quake2BspPlane * planes; int numPlanes; Quake2BspNode * nodes; int numNodes; Quake2BspLeaf * leaves; int numLeaves; unsigned short * leafFaces; // indices of faces // for any leaf Quake2Texinfo * texinfos; int numTexInfos;
unsigned char * vis; // // visibility info lump int visBytes; void Quake2Hull int char BspFile ( Data J -BspFile (); * lightmaps; * hulls; numHulls; * entities; data ) ; // lightmaps lump int getNumFaces () const ( return nuniFaces; int getNumClusters () const { return * (long *) vis; int getPvsOffset ( int cluster ) const { return ((Quake2PvsEntry *)(4 + cluster * sizeof (Quake2PvsEntry) + (char *) vis)) -> pvsOffs; void * getLightMap ( long offset ) const { return offset + (unsigned char *) lightmaps; Приведем конструктор для этого класса. о, BspFile :: BspFile ( Data * data ) { void * ptr = malloc ( data -> getLength () ); if ( ptr == NULL ) return; data -> getBytes ( ptr, data -> getLength () ); hdr = (Quake2BspHeader *) ptr;
vertices numvertices edges numEdges faces numFaces planes numPlanes nodes numNodes leaves numLeaves leafFaces texinfos numTexInfos vis visBytes lightmaps hulls (Vector3D *)(hdr -> dir [LUMP-VERTICES].offset + (char *) ptr); hdr -> dir [LUMP_VERTICES].size / sizeof ( Vector3D ); (Quake2Edge *)(hdr -> dir [LUMP_EDGES].offset + (char *) ptr); hdr -> dir [LUMP_EDGES].size / sizeof ( Quake2Edge ); (Quake2Face *)(hdr -> dir [LUMP—FACES].offset + (char *) ptr); hdr -> dir [LUMP_FACES].size / sizeof ( Quake2Face ); (Quake2BspPlane *)(hdr -> dir [LUMP—PLANES].offset+(char *) ptr); hdr -> dir [LUMP—PLANES].size / sizeof ( Quake2BspPlane ); (Quake2BspNode *)(hdr -> dir [LUMP—NODES].offset+(char *) ptr); hdr -> dir [LUMP_NODES].size / sizeof ( Quake2BspNode ); (Quake2BspLeaf *)(hdr -> dir [LUMP—LEAVES].offset+(char *) ptr); hdr -> dir [LUMP_LEAVES].size / sizeof ( Quake2BspLeaf ) ; (unsigned short *)(hdr -> dir [LUMP_LEAF_FACE_TABLE].offset + (char*) ptr); (Quake2TexInfo *)(hdr -> dir [LUMP—TEXINFO].offset + (char *) ptr); hdr -> dir [LUMP—TEXINFO].size / sizeof ( Quake2TexInfo ); (unsigned char *)(hdr -> dir [LUMP—VIS] .offset + (char *) ptr) ; hdr -> dir [LUMP—VIS].size; hdr -> dir [LUMP_LIGHTMAPS].offset + (char *) ptr; (Quake2Hull *)(hdr -> dir [LUMP—HULLS].offset + (char *) ptr);
numHulls = hdr -> dir [LUMP_HULLS].size / sizeof ( Quake2Hull ); entities = (char *)(hdr -> dir [LUMP_ENTITIES].offset + (char *) ptr); faceEdges = (unsigned long *)(hdr -> dir [LUMP_FACE„EDGE_TABLE].offset + (char *) ptr); Этот класс позволяет автоматически разбивать bsp-фат на блоки и хранит необходимую информацию для доступа к каждому из этих блоков. . Отдельному уровню игры соответствует объект, класс которого должен быть унаследован от класса Model. Приведем описание такого файла. class Quake2Level : public Model { private: BspFile * file; long * clusterTable; // table of visible clusters // (cluster visible // if value == frameNo) long * faceTable; // table of visible facets // (bit per face) Entities * entities; Polygon3D ** polys; // pointers to polygons //of the level Polygon3D ** visPolys; // list of visible polys int numFaces; int numclusters; int numVisibleFaces; float angle; // angle of player (yaw ?) Sky * sky; Bool drawSky; Long frameNo; // current frame number // (count of drawn frames) public: Quake2Level ( Data * data ); ~Quake2Level (); virtual int init ( );
bool loadPolys (); bool isLeafInFrustrum ( const Quake2BspLeaf * leaf, const Frustrum& frustrum ) const; Quake2BspLeaf * findLeaf ( const Vector3D& pos ) const; void buildClusterTable ( const Vector3D& pos ); void addFacesList ( int firstFace, int numFaces, const Vector3D& pos ); void buildFacesList ( const Camera& camera ) ; void drawFaces ( View& view, const Camera& camera ); void render ( View& view, const Camera^ camera ); void setLightmap ( int offset, Polygon3D * poly ); const Vector3D& getStartPos () const { return pos; } float getAngle () const { return angle; } void markFace ( int index ) { faceTable [index] = frameNo; } bool isFaceMarked ( int index ) const { return faceTable [index] == frameNo; } Метод loadPolys данного класса служит для загрузки информации о гранях и перевода ее в объекты класса Polygon3D. is? bool Quake2Level :: loadPolys () { String dir ( "textures/" );
for ( int i = 0; i < numFaces; i++ ) { Quake2Face * face = &file -> faces [i]; Quake2TexInfо * texinfo = &file -> texinfos [face -> texinfo]; Quake2BspPlane * qPlane = &file -> planes [face -> plane]; String texName = texinfo -> texName; Texture * texture = Application :: instance --> getResourceManager () -> getTexture ( dir + texName + ".wal" ); float txtwidth = (float)texture -> getWidth (); float txtHeight = (float)texture -> getHeight (); Mapping mapping ( texinfo -> uAxis / txtwidth, texinfo -> uOffset / txtwidth, texinfo -> vAxis / txtHeight, texinfo -> vOffset / txtHeight ); polys [i] = new Polygon3D ( , face -> numEdges ); polys [i] -> setTexture ( texture ); polys [i] -> setMapping ( mapping ); for ( int j = 0; j < face -> numEdges; j++ ) { int edge = file -> faceEdges [face -> firstEdge + j]; int vertex; if ( edge < 0 ) vertex = file -> edges [-edge].lastEdge; else vertex = file -> edges [edge].firstEdge; polys [i] -> addVertex ( file -> vertices [vertex] ); } polys [i] -> init (); setLightmap ( face -> lightmapOffset, polys [i] );
// we need to set our plane due to some // glitches in original QII file Plane plane ( qPlane -> normal.x, qPlane -> normal.y, qPlane -> normal.z, -qPlane -> dist ) ; if ( face -> planeSide ) plane.flip (); polys [i] -> setPlane ( plane ); } return true; } Метод render этого класса крайне прост: применяется камера, по положению камеры строится список видимых кластеров (buildClusterTable), по этому списку строится список потенциально видимых граней (buildFaces-List) и грани из этого списка выводятся (drawFaces). void Quake2Level :: render ( View& view, const Cameras camera ) { view.apply ( camera ); buildClusterTable ( camera.getPos () ); // build a list of visible clusters buildFacesList ( camera ); // compute a list of all potentially // visible faces drawFaces ( view, camera ) ; // draw potentially visible faces frameNo++; } Процедура построения списка видимых кластеров состоит из двух шагов - определения текущего листа дерева и декодирования RLE-сжатого списка потенциально видимых кластеров. S void Quake2Level :: buildClusterTable ( const Vector3D& pos ) { Quake2BspLeaf * leaf = findLeaf ( pos );
if ( leaf == NULL || leaf -> cluster >= numclusters ) return; long offs = file -> getPvsOffset ( leaf -> cluster ); unsigned char * pvs = offs + (unsigned char *) file -> vis; for ( int i = 0, cl = 0; cl < numclusters; i++ ) if ( pvs [i] == 0 ) // RLE'd zeros cl += 8 * pvs [++i]; else for ( unsigned char bit = 1; bit != 0; bit *= 2, cl++ ) if ( pvs [i] & bit ) clusterTable [cl] = frameNo; } Для определения листа дерева, соответствующего текущему положению камеры, служит \Kio}ifmdLeaf. Quake2BspLeaf * Quake2Level :: findLeaf ( const Vector3D& pos ) const { int index = file -> hulls [0].headNode; while ( index >= 0 ) { Quake2BspNode * node. = &file -> nodes [index]; Quake2BspPlane * plane = &file -> planes [node -> plane]; if ( (plane -> normal & pos) >= plane -> dist ) index = node -> frontchild; else index = node -> backChild; index = -(index +1); // -index if ( index <0 | | index >= file -> numLeaves ) return NULL; return &file -> leaves [index];
Этот метод просто обходит дерево, начиная с корня выбирая каждый раз то поддерево, в котором находится камера, до тех пор, пока не дойдет до листа. В результате работы метода buildClusterTable в массив clusterTable на место, соответствующее кластеру, записывается номер текущего кадра (frameNo) в том случае, если этот лист является потенциально видимым в данном кадре. Для более точного отсечения невидимых граней по списку потенциально видимых кластеров строится список потенциально видимых граней. Для этого для каждого листа дерева проверяется, попал ли соответствующий кластер в список видимых кластеров (clusterTable [leaf->cluster] == frameNo), и если да, то в случае попадания данного листа в область видимости камеры все грани этого листа записываются в список видимых граней (vis-Polys). Одновременно проверяется, не соответствует какой-либо из видимых граней небо (подробнее о рендеринге неба в следующей главе). Если да, то устанавливается флаг видимости неба в данном кадре. S void Quake2Level :: buildFacesList ( const Camera& camera ) { Quake2BspLeaf * leaf = file -> leaves; for ( int i = 0; i < file -> numLeaves; i++, leaf++ ) if ( clusterTable [leaf -> cluster] == frameNo ) // cluster is marked as visible if ( isLeafInFrustrum ( leaf, camera.getViewFrustrum () ) ) addFacesList ( leaf -> firstLeafFace, leaf -> numLeafFaces, camera.getPos () ); numVisibleFaces = 0; drawSky = false; for ( i = 0; i < numFaces; i++ ) if ( isFaceMarked ( i ) ) { Quake2Face * face = &file -> faces [i]; if ( file -> texinfos [face -> texinfo].flags & SURF_SKY ) drawSky = true;
else visPolys [numVisibleFaces++] = polys [ i ] ; } } void Quake2Level :: addFacesList ( int firstFace, int count, const Vector3D& pos ) { for ( int i = 0; i < count; i++ ) { int faceNo = file -> leafFaces [firstFace + i] ; // add it, if it's a front face if ( Ipolys [faceNo] -> isOk () ) / / check whether plane is defined continue; if ( polys [faceNo] -> isFrontFacing ( pos ) ) if ( ! isFaceMarked ( faceNo ) ) // if not already marked -> mark markFace ( faceNo ); } } Наконец, для вывода всех граней из списка видимых служит следующий метод: О| void Quake2Level :: drawFaces ( View& view, const Cameras camera ) { view.lock (); view.apply ( camera ); // draw polys for ( int i = 0; i < numVisibleFaces; i++ ) view.draw ( *visPolys [i] ); // draw lightmaps glPushAttrib ( GL_COLOR_BUFFER_BIT | GL_TEXTURE_BIT | GL_DEPTH_BUFFER_BIT ) ; glDepthFunc ( GL_LEQUAL ) ; glDepthMask ( GL_FALSE ); glEnable ( GL_BLEND );
glBlendFunc ( GL_ONE, GL_SRC_COLOR ); glColor4f ( 1, 1, 1, 1 ); for ( i = 0; i < numVisibleFaces; i++ ) { Polygon3D * poly = visPolys [i]; Lightmap * lightmap = poly -> getLightmap (); view.bindTexture ( lightmap -> getTexture () ); view. simpleDraw ( *poly, View :: useLightmap ); glPopAttrib (); if ( drawSky ) sky -> draw ( camera ); view.unlock ();
Глава 12. ДОБАВЛЯЕМ ЭФФЕКТЫ Используя код, рассмотренный в предыдущих главах, можно написать достаточно красивый рендерер, однако существует ряд весьма простых эффектов, способных значительно улучшить визуальное впечатление от вашей игры. В этой главе мы рассмотрим некоторые из таких эффектов, которые, с одной стороны, достаточно просты в реализации, а с другой - способны сделать вашу сцену гораздо привлекательнее и разнообразнее. Небо Одним из достаточно простых эффектов является создание неба, которое можно видеть из окна и открытого пространства. Существует достаточно простой способ создания реалистически выглядящего неба. Для этого небо представляют в виде куба со сторонами, параллельными осям координат, и с центром в положении наблюдателя (рис. 12.1). Размер куба должен быть достаточно большим, чтобы избежать сильного перспективного искажения. На каждую грань этого куба натягивается текстура с изображением соответствующего вида. По аналогии с игрой Quake 11 мы будем добавлять к имени файла с текстурой следующие суффиксы: "ft", "bk”, "If, "rt", "up" и "dn" - для обозначения сторон. Ниже приводится описание класса Sky, строящего изображение неба. 'П О*. class Sky : public Object { protected: float skySize; Texture * skyTextures [6];
float invWidth [6]; float invHeight [6]; public: Sky ( const char * name, const char * ext = ".tga" ); -Sky (); virtual bool isOk () const; virtual void draw ( const Cameras camera ); static MetaClass classinstance; } ; Метод draw этого класса достаточно прост, единственной его особенностью является небольшой сдвиг текстурных координат в углах куба. Если его не делать, то между гранями куба могут появиться черные линии. О| void Sky :: draw ( const Cameras camera ) { Vector3D pos BoundingBox frontBox ( camera.getPos () ); ( Vector3D (pos.x-skySize, pos.y+skySize, pos.z-skySize), Vector3D (pos.x+skySize, pos.y+skySize, pos.z+skySize)) BoundingBox backBox ( Vector3D(pos.x-skySize, pos.y-skySize, pos.z-skySize), Vector3D(pos.x+skySize, pos.y-skySize, pos.z+skySize)) BoundingBox leftBox ( Vector3D(pos.x-skySize, pos.y-skySize, pos.z-skySize), Vector3D(pos.x-skySize, pos.y+skySize, pos.z+skySize)) BoundingBox rightBox ( Vector3D(pos.x+skySize, pos.y-skySize,pos.z-skySize), Vector3D (pos.x+skySize, pos.y+skySize, pos.z+skySize)) BoundingBox upBox ( Vector3D(pos.x-skySize, pos.y-skySize, pos.z+skySize), Vector3D(pos.x+skySize, pos.y+skySize, pos.z+skySize))
BoundingBox downBox ( Vector3D(pos.х-skySize, pos.y-skySize,pos.z-skySize), Vector3D(pos.x+skySize, pos.y+skySize, pos.z-skySize)); if ( camera.inViewingFrustrum ( frontBox ) ) { glBindTexture glBegin ( GL_TEXTURE_2D, skyTextures [0] -> getld () ); ( GL-QUADS ); glTexCoord2f glVertex3d ( l.Of - invWidth [0], invHeight [0] ( pos.x - skySize, pos.у + skySize, pos.z + skySize ); glTexCoord2f glVertex3f ( invWidth [0], invHeight ( pos.x + skySize, pos.у + pos.z + skySize ); [0] ); skySize, glTexCoord2f glVertex3 f ( invWidth [0], l.Of - invHeight [0] ( pos.x + skySize, pos.у + skySize, pos.z - skySize ); glTexCoord2f glVertex3f ( l.Of - invWidth [0], l.Of - invHeight [0] ) ,- ( pos.x - skySize, pos.у + pos.z - skySize ); skySize, } glEnd () ; if ( camera.inViewingFrustrum ( backBox ) ) { glBindTexture ( GL_TEXTURE_2D, skyTextures [1] -> getld glBegin ( GL_QUADS ); 0 ); glTexCoord2f glVertex3d ( invWidth [1], invHeight ( pos.x - skySize, pos.у -pos.z + skySize ); [1] ); skySize, glTexCoord2f glVertex3 f ( invWidth [1], l.Of - invHeight [1] ( pos.x - skySize, pos.у - skySize, pos.z - skySize ); glTexCoord2f ( l.Of - invWidth [1], 1.0 invHeight [1] ); f -
glVertex3f ( pos.x + skySize, pos.у - skySize, pos.z - skySize ); glTexCoord2f glVertex3 f ( l.Of - invWidth [1], invHeight [1] ); ( pos.x + skySize, pos.у - skySize, pos.z + skySize ); glEnd 0 ; } if ( camera.inViewingFrustrum ( leftBox { glBindTexture ( GL_TEXTURE_2D, skyTextures [2] -> getld () ); glBegin ( GL_QUADS ); glTexCoord2f glVertex3d ( l.Of - invWidth [2], invHeight [2] ); ( pos.x - skySize, pos.у - skySize, pos.z + skySize ); glTexCoord2f glVertex3f ( invWidth [2], invHeight [2] ); ( pos.x - skySize, pos.у + skySize, pos.z + skySize ); glTexCoord2 f glVertex3 f ( invWidth [2], l.Of - invHeight [2] ); ( pos.x - skySize, pos.у + skySize, pos.z - skySize ); glTexCoord2 f ( l.Of - invWidth [2], l.Of - invHeight [2] ); glVertex3 f ( pos.x - skySize, pos.у - skySize, pos.z - skySize ); glEnd 0 ; . } if ( camera.inViewingFrustrum ( rightBox ). { glBindTexture ( GL_TEXTURE_2D, skyTextures [3] -> getld () ); glBegin ( GL_QUADS ); glTexCoord2f glVertex3d ( l.Of - invWidth [3], invHeight [3] ); ( pos.x + skySize, pos.у + skySize, pos.z + skySize );
glTexCoord2f glVertex3 f ( invWidth [3], invHeight [3] ); ( pos.x + skySize, pos.у - skySize, pos.z + skySize }; glTexCoord2f glVertex3f ( invWidth [3], l.Of - invHeight [3] ( pos.x + skySize, pos.у - skySize, pos.z - skySize ); glTexCoord2f glVertex3 f ( l.Of - invWidth [3], l.Of - invHeight [3] ); ( pos.x + skySize, pos.у + skySize, pos.z - skySize ); } glEnd 0 ; if { ( camera.inViewingFrustrum ( upBox ) ) glBindTexture ( GL_TEXTURE_2D, skyTextures [4] -> getld () ); glBegin ( GL_QUADS ); glTexCoord2f glVertex3d ( invWidth [4], l.Of - invHeight [4] ( pos.x + skySize, pos.у - skySize, pos.z + skySize ); glTexCoord2f glVertex3 f ( l.Of - invWidth [4], l.Of - invHeight [4] ); ( pos.x + skySize, pos.у + skySize, pos.z + skySize ); glTexCoord2f glVertex3 f ( l.Of - invWidth [4], invHeight [4] ( pos.x - skySize, pos.у + skySize, pos.z + skySize ); glTexCoord2f glVertex3 f ( invWidth [4], invHeight [4] ); (.pos.x - skySize, pos.у - skySize, pos.z + skySize ); } glEnd () ; if { ( camera.inViewingFrustrum ( downBox ) ) glBindTexture ( GL_TEXTURE_2D, skyTextures [5] -> getld (} ); glBegin ( GL_QUADS );
glTexCoord2f glVertex3d ( invWidth [5], l.Of - invHeight [5] ) ; ( pos.x - skySize, pos.у - skySize, pos.z - skySize ); glTexCoord2 f ( l.Of - invWidth [5], l.Of - invHeight [5] }; glVertex3f ( pos.x - skySize, pos.у •+ skySize, pos.z - skySize }; glTexCoord2f glVertex3f ( l.Of - invWidth [5], invHeight [5] ); ( pos.x + skySize, pos.у + skySize, pos.z - skySize ); glTexCoord2f glVertex3 f ( invWidth [5], invHeight [5] ); ( pos.x + skySize, pos.у - skySize, pos.z - skySize }; glEnd () ; } } Для поддержки эффекта неба в нашем рендерере добавим в загрузчик сцены следующую команду, которую можно задавать для каждой комнаты: sky sky-name Здесь sky-name является именем текстуры с путем и без суффиксов и расширений. Расширения и тип .tga добавляются автоматически. Ряд текстур для неба содержится на компакт-диске в каталоге Textures/Skies. Обратите внимание на то, что у различных комнат может быть разное небо. Объемный туман Еще одним достаточно простым эффектом является объемный туман. В библиотеке OpenGL есть стандартная поддержка тумана, когда грань затеняется пропорционально расстоянию до нее. Это делается при помощи функции glFog*. Однако гораздо чаще встречается другой вид тумана -слой тумана над полом, когда затеняется только часть комнаты, лежащая в слое тумана (рис. 12.2). Функция glFog для создания такого тумана явно не подходит.
При этом степень затенения фактически определяется интегралом от плотности тумана вдоль отрезка луча от положения камеры С до точки Р на грани. Z = |р(т)</т. (12.1) с Тогда результирующий цвет в точке Р определяется следующей формулой: ,, х fFT>l, С(Р)= , . (12.2) V 7 [C(P)(l-z)+ Ft, 0 <t < L, где C'(P) - результирующий цвет с учетом тумана; F - цвет тумана; С(Р) -исходный цвет точки. Преобразование (12.2) можно представить как результат смешения цветов (blending) исходного многоугольника и многоугольника со значением цвета, равным F, и значением «-компоненты, равным min(t, 1). Таким образом, для реализации эффекта тумана достаточно вывести поверх текстурированного многоугольника точно такой же многоугольник, но с текстурой, соответствующей степени затенения соответствующих точек туманом. Достаточно простым способом построения такого многоугольника является использование исходного многоугольника, но вместо текстуры в вершинах задаются значения цвета, равные (F, t). Эти значения вычисляются во всех вершинах, и для их нахождения достаточно оттрассировать лучи от положения наблюдателя до каждой вершины многоугольника. При этом средствами OpenGL эти значения будут билинейно интерполироваться на весь многоугольник. Вдоль отрезка луча, лежащего внутри тумана, плотность тумана интегрируется для получения величины t.
Проще всего реализовать это, если считать, что область, заполненная туманом, представляет собой выпуклый многогранник, т. е. область ограничивается набором плоскостей. Тогда пересечение луча с таким множеством является отрезком. Для определения координат начала и конца этого отрезка можно использовать стандартный метод трассировки лучей. Находятся точки tt пересечения луча со всеми плоскостями, и для каждой такой точки определяется, входит ли луч в соответствующее полупространство или выходит из него. Тогда началом отрезка будет максимум - из значений t., соответствующих вхождению луча, а концом отрезка минимум из значений , соответствующих выходу луча (рис. 12.3). Луч считается входящим в соответствующее полупространство, если начало луча не лежит в этом полупространстве, и выходящим в противном случае. Рассмотрим сначала простейший случай тумана с постоянной плотностью р. Тогда интеграл от t' до t* равен (z*-z')-p . Объект, реализующий такой туман, можно представить в виде экземпляров следующего класса: Н class Fog : public Object ( private: Vector3D color; // fog color float density; // fog density int numPlanes; Plane ’ k plane [MAX_FOG_PLANES]; // fog planes public: Fog ( const char * theName, Plane * thePlane, const Vector3D& theColor, float theDensity ) Object ( theName }
color = theColor; numPlanes = 1; plane [0] = thePlane; density = theDensity; metaClass = Sclasslnstance; -Fog (); bool addFogPlane ( Plane * thePlane ); virtual void drawFogPoly ( Views view, const Vector3D& pos, const Frustrum& viewFrustrum, const Polygon3D& poly ) const; virtual float getOpacity ( const Vector3D& from, const Vector3D& to. Plane * clipPlane ) const; bool traceRay ( const Ray& ray, floats tl, floats t2 ) const; Vector4D getBlendColor ( const Vector3DS from, const Vector3DS to. Plane * clipPlane ) const { return Vector4D ( color.x, color.y, color.z, getOpacity ( from, to, clipPlane ) ); } static MetaClass classinstance; } ; В этом классе область, заполненная туманом, представляется как пересечение полупространств. Добавить очередную плоскость можно при помощи метода addFogPlane. Метод traceRay определяет отрезок луча, лежащий в области тумана (находит пересечение луча со всеми положительными полупространствами). Для определения интеграла от плотности тумана служит метод getOpacity, который может переопределяться в подклассах. Метод drawFogPoly выводит с соответствующим режимом наложения многоугольник, имитирующий затуманивание.
Используя метод getOpac-ity, можно легко найти значения затенения для любой точки многоугольника. Тем самым легко построить затеняющий многоугольник, однако такой простой подход обладает определенным недостатком. Рассмотрим ситуацию, изображенную на рис. 12.4. Значения затенения для точек и Рг равны между собой, но заметно отличаются от значения в точке М. Поэтому если нарисовать многоугольник по точкам Р{ и Р2 с использованием линейной интерполяции, то затенение на нем будет постоянным и в точке М затенение будет таким же, как во всех остальных точках, что неправильно (поскольку расстояние от точки С до М заметно меньше расстояния до ZJ и Р2). Эту ситуацию легко исправить путем добавления всего одной дополнительной точки, а именно ближайшей к наблюдателю точки на . многоугольнике. В случае, изображенном на рис. 12.4, такой точкой будет сама 2 точка М. При добавлении этой точки в ней также вычисляется значение затененности и эта точка разбивает многоугольник на веер треугольников (рис. 12.5). Обратите внимание, что при таком способе вывода грани вершина должна быть выведена дважды. С учетом этого метод drawFogPoly выглядит следующим образом:
void Fog :: drawFogPoly ( View& view, const Vector3D& pos, const Frustrum& viewFrustrum, const Polygon3D& poly ) const { Polygon3D Vector4D Vector3D Vector3D p [2] ; curColor; closestPoint; pt; // temp polys for splitting poly.split ( *plane [0], p [0] , p [1] ) ; // p [0] lies in the front // (g.e. in the fog) poly.getPlane () -> closestPoint ( pos, pt ); curColor.x = color.x; curColor.y = color.y; curColor.z = color.z; glPushAttrib ( GL_COLOR_BUFFER_BIT | GL_ENABLE_BIT | GL_DEPTH_BUFFER_BIT ); glDisable ( gl_ _TEXTURE_2D glDisable ( GL_ -LIGHTING glEnable ( GL_ _BLEND glDepthMask ( GL. .FALSE view.blendFunc ( srcBlend, dstBlend ); for ( int i = 0; i < 2; i++ ) { if ( p [i].isEmpty () ) continue; p [i] . init () ,- if ( !p [i].contains ( pt ) ) // point, closest to the plane // is not in the poly // find closest point on // poly's boundary p [i].closestPointToBoundary ( pt, closestPoint ); else // closest point already lies in // the poly closestPoint = pt;
// move point forward, since // due to the innacuracies of // OpenGL we sometimes get "lost" // points in fog closestPoint += DELTA * poly.getPlane () -> n; // now draw the triangle fan on // these points curColor.w = getOpacity ( pos, closestPoint, viewFrustrum.getNearPlane () ); glBegin ( GL_TRIANGLE_FAN ); glColor4fv ( curColor ); glVertex3fv ( closestPoint ); const Vector3D * vertices = p [i].getVertices (); for ( int j = 0; j < p [i].getNumVertices (); j++ ) { curColor.w = getOpacity ( pos, vertices [j], viewFrustrum.getNearPlane () ); glColor4fv ( curColor ); glVertex3fv ( vertices [j] }; } curColor.w = getOpacity ( pos, vertices [0], viewFrustrum.getNearPlane () ); glColor4fv ( curColor ); glVertex3fv ( vertices [0] ); glEnd () ; } glPopAttrib (); } Недостатком использования постоянной плотности тумана является резкий скачок плотности при переходе через плоскость, ограничивающую туман. В ряде случаев этого можно легко избежать, если использовать линейно изменяющуюся плотность, т. е. считать, что плотность тумана в точке Р задается формулой р(Р) = (Р^)+а (12.3)
Для интегрирования такой плотности можно использовать метод трапеций, причем нет необходимости в разбиении отрезка интегрирования, поскольку в случае линейной подынтегральной функции метод трапеций всегда дает точное значение интеграла вне зависимости от разбиения отрезка интегрирования. Такой туман можно представить в виде экземпляров следующего класса: Е1 class LinearFog : public Fog' { private: Vector3D grad; float offs; public: LinearFog ( const char * theName, Plane * thePlane, const Vector3D& theColor, const Vector3D& theGrad, float theOffs ) : Fog ( theName, thePlane, theColor, l.Of ) { grad = theGrad; offs = theOffs; metaClass = kclasslnstance; } virtual float getOpacity ( const Vector3D& from, const Vector3D& to, Plane * clipPlane ) const; static MetaClass classinstance; } ; Метод getOpacity для этого класса выглядит следующим образом: float LinearFog :: getOpacity ( const Vector3D& from, const Vector3D& to, Plane * clipPlane ) const { Ray ray ( from, to - from ); // ray to sample fog float tl, t2; // points of entering // and leaving float t; tl = 0; t2 = from.distance ( to );
if ( !traceRay ( ray, tl, t2 ) ) return 0; // now clip segment [tl, t2] to clipPlane if ( clipPlane != NULL && clipPlane -> intersectByRay ( ray.getOrigin(), ray.getDir ( ) , t ) ) { bool clipFlag = clipPlane -> classify ( from ) == IN_FRONT; if ( clipFlag ) // ray is entering clipPlane { if ( t > tl ) tl = t; } else { if ( t < t2 ) t2 = t; } float densl = (ray.point ( tl ) & grad) + offs; // density at point tl float dens2 = (ray.point ( t2 ) & grad) + offs; // density at point t2 // now integrate density using trapzoid rule float opacity = 0.5f * (densl + dens2) * (t2 - tl); return (opacity > l.Of ? l.Of : opacity); } Обратите внимание на то, что данный подход может некорректно обрабатывать полупрозрачные грани, находящиеся в тумане, - грани, видимые сквозь полупрозрачные грани, будут "затуманены" дважды: один раз при их выводе и еще один раз при выводе полупрозрачной грани. Для поддержки объемного тумана следует внести изменение в класс SubScene - внутри каждой комнаты должен теперь храниться указатель на туман (объект класса Fog) и метод drawPoly должен при наличии тумана для каждого многоугольника вызывать метод drawFogPoly. Мы считаем, что внутри комнаты может находиться не более одного объекта, задающего объемный туман.
Для задания объемного тумана также понадобится введение дополнительных команд в формат описания сцены. Для этого добавим две новые команды fog и linear-fog, которые могут использоваться внутри комнаты для задания тумана с постоянной плотностью и тумана с линейно изменяющейся плотностью соответственно. Первая из них имеет вид fog { plane (nx,ny, nz) dist color (r, g, b) density dens } Вторая команда имеет вид linear-fog { plane (nx,ny, nz) dist color (r, g, b) offset offs gradient (gx, gy, gz) } При этом для простоты считается, что туман задается всего одной плоскостью (это ограничение только класса SceneDecoder). Используемые параметры задают следующие величины: (их, пу, nz) Нормаль к плоскости, ограничивающей туман dist Расстояние со знаком от плоскости к началу координат (Г, g, b) Цвет тумана dens Плотность тумана (для тумана с постоянной плотностью) (%X, gy, gz) Градиент тумана (см. формулу (12.3) offs Второй коэффициент из формулы (12.3) Микрофактурные текстуры Использование пирамидального фильтрования (mipmapping) в OpenGL позволяет легко бороться с искажениями, возникающими при рендеринге сильно удаленных граней. Однако определенные проблемы могут возникать и при рендеринге близко расположенных к наблюдателю (камере) граней. В этом случае одному пикселу текстуры соответствует сразу несколько пикселов экрана. Это приводит к явной блочной структуре изображения грани (если интерполяция текстуры выключена), или же блочная структура
присутствует, но сильно размыта (в случае применения интерполяции текстуры). В первом случае артефакты наиболее заметны и сразу бросаются в глаза, но и во втором случае они легко заметны - на изображении грани отсутствуют мелкие детали, придающие изображению реалистичность. Очевидный шаг - увеличение разрешения текстуры - ведет к существенному увеличению объема памяти, требуемого для хранения текстуры (при увеличении разрешения текстуры в 2 раза и по ширине и по высоте требуемый объем памяти возрастает в 4 раза). Кроме того, подобный подход проблему не решает, просто "критическое расстояние" чуть отодвигается. Одним из достаточно простых способов борьбы с этим неприятным эффектом, не прибегая к значительному увеличению размеров текстуры, является использование так называемых микрофасетных текстур (detail textures). Для создания требуемой детализации поверх основной текстуры выводится специальная текстура, модулирующая основную. При этом для вывода этой дополнительной текстуры используется преобразование текстурных координат, умножающее их на достаточно большую величину (10-50 раз). За счет использования этого преобразования не происходит "размытия" этой текстуры и на строящемся изображении появляются необходимые детали. При этом такая текстура может быть всего одна для всех граней, тем самым требования к объему памяти возрастают крайне незначительно. Обычно в качестве такой текстуры (мы будем далее называть ее микро-фасетной) используется изображение в градациях серого цвета (grey-scale) с досточно большим разрешением. Способ вывода микрофасетной текстуры обычно берется равным (GL_DST_COLOR, GLSRCCOLOR), что приводит к тому, что результирующий цвет пиксела определяется следующей формулой: С = 2-С . -С, , (12.4) '“'res ''main del 4 где Cmajn - цвет соответствующего пиксела основной текстуры; Crff, - цвет микрофасетной текстуры. Таким образом, если взять микрофасетную текстуру со средним значении цвета, равным 0.5, то среднее значение цвета грани практически не изменится. Очевидно, что вывод микрофасетной текстуры на удаленные от наблюдателя грани практически не изменяет получающегося изображения (если среднее значение цвета микрофасетной текстуры равно 0.5) и поэтому является лишь напрасной тратой ресурсов, увеличивая затраты на построение изображения сцены.
Исходя из этого, удобно осуществлять наложение микрофасетной текстуры лишь на грани, расположенные к наблюдателю ближе определенного расстояния. Подобное расстояние, равно как и используемая текстура и масштаб преобразования текстурных координат для ее вывода, можно задавать в конфигурационном файле. Также удобно выводить микрофасетные текстуры все сразу, а не по одной при выводе каждой грани. Тем самым процедура вывода граней проверяет, следует ли накладывать на данную грань микрофасетную текстуру, и если да, то запоминает ссылку на данную грань. После вывода всех непрозрачных граней комнаты происходит наложение микрофасетной текстуры -тем самым задание микрофасетной текстуры и текстурного преобразования происходит всего один раз для каждой комнаты. Для осуществления этой операции добавим метод drawDetails в класс SubScene. jbJ void SubScene :: drawDetails ( View& view, const Array& polyList ) const { if ( World :: detailTexture == NULL ) return; glPushAttrib ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT ); glDepthFunc ( GL_EQUAL ); glDepthMask ( GL_FALSE ); glEnable ( GL_BLEND ); glBlendFunc ( GL_DST_COLOR, GL_SRC_COLOR ); glMatrixMode ( GL_TEXTURE ); glPushMatrix (); glScalef ( World :: detailTextureScale, World :: detailTextureScale, World :: detailTextureScale ); glColor3f ( 1, 1, 1 ); //in case it has other value from prev calls view.bindTexture ( World :: detailTexture ); for ( Array :: Iterator it = polyList.getlterator (); lit.end (); ++it ) { Polygon3D * poly = (Polygon3D *) it.value (); view.simpleDraw ( *poly, 0 );
glPopMatrix (); glPopAttrib (); } Задание параметров микрофасетной текстуры осуществляется через статические поля объекта World. Проверка того, следует ли накладывать микрофасетную текстуру на данную грань, может быть реализована следующим образом: 13 // check for detail texture if ( poly -> testFlag ( PF_HAS_DETAIL_TEXTURE ) && World :: detailTexture != NULL ) if ( poly -> getBoundingBox ().getDistanceTo ( camera.getPos (),camera.getViewDir () ) < World :: detailDistance ) detaiIPolys.insert ( poly ); Панели (billboard) Одним из достаточно часто встречающихся объектов (в частности, мы будем использовать их при работе с системами частиц) являются прямоугольные грани, всегда расположенные параллельно картинной плоскости. Такие объекты, как правило, задаются при помощи положения своего центра и своего размера, а их точная ориентация определяется ориентацией камеры. Обычно они называются панелями (billboard). Рассмотрим, каким образом можно построить такой объект. Камера определяет систему координат наблюдателя и соответствующий ей ортонормированный базис (векторы view, up и right). При этом картинная плоскость строится по векторам следующим образом: р = еуе + х-right + yup , (12.5) где величины х и у пробегают все действительные значения. Теперь если нужно построить прямоугольник с центром в точке pos, размером width х height и параллельный картинной плоскости, то он будет задаваться следующей формулой (рис. 12.6): р = pos + х right + у-up, |х| < width/2, (12.6) |у| < height/2.
Для облегчения работы с панелями добавим соответствующие методы в классы View и OpenGIView. ЕЛ чЛ. virtual void drawBillboard ( const Vector3D& pos, float size, const Vector4D& color, Texture * txt = NULL ); virtual void drawBillboard ( const Vector3D& pos, float width, float height, const Vector4D& color, Texture * txt = NULL ); Их реализация выглядит следующим образом: void OpenGIView :: drawBillboard ( const Vector3D& pos, float width, float height, const Vector4D& color, Texture * txt ) { width *= 0.5f; height *= 0.5f; . : ы V ector3D r ( camera -> getRightDir () * width ); V ector3D u ( camera -> getUpDir () * height ); V ector3D vl ( pos - r - u ); V ector3D v2 ( pos + r - u ); V ector3D v3 ( pos + r + u ); V ector3D v4 ( pos - r + u );
if ( txt != NULL ) if ( txt -> getld () != lastTextureld ) glBindTexture ( GL_TEXTURE_2D, lastTextureld = txt->getld () ); glBegin ( GL_POLYGON ); glColor4fv ( color ); glTexCoord2f glVertex3fv ( O.Of, ( vl ) ; O.Of glTexCoord2f glVertex3 fv ( l.Of, ( v2 ) ; 0 .Of glTexCoord2f glVertex3fv ( l.Of, ( v3 ) ; l.Of glTexCoord2f glVertex3fv ( O.Of, ( v4 ) ; 1 .Of glEnd(); } Поскольку панель не является многоугольником, аналогичным граням сцены (она задается всего одной точкой, и размер ее и ориентация зависят от положения камеры, и, следовательно, не является экземпляром класса Polygon3D), так же как и различные модели, то будет удобным добавить в класс SubScene поддержку подобных неполигональных объектов. В первую очередь каждый такой объект должен уметь нарисовать себя. Кроме того, будет удобно сразу добавить в этот класс поддержку анимации. При этом возникает необходимость регулярно обновлять состояние объекта, за что, естественно, должна отвечать содержащая его комната. Поэтому подобный объект мы будем далее представлять при помощи следующего класса: class Visualobject : public Object { protected: Vector3D pos; // position of the object Vector4D color; // color to modulate object Transform3D transform; // original transform // applied to objects // (before animators)
Transform3D curTransform; // // // current transform from original by animators) (build Array BoundingBox animators; boundingBox; // bounding box of object int saveFlags; // what state values to save public: Visualobject ( const char * theName, const Vector3D& thePos, const Vector4D& theColor, const Transform3D& theTr ); VisualObject ( const char * theName, const Vector3D& thePos, const Vector4D& theColor ); VisualObject ( const char * theName, const Vector3D& thePos ); const Vector3D& getPos () const { return pos; } void setPos ( const Vector3D& thePos ) { pos = thePos; buildBoundingBox (); } const Vector4D& getColor () const { return color; } void setColor ( const Vector4D& theColor ) { color = theColor; } const Transform3D getTransform () const { return transform; }
void. setTransform ( const Transform3DS theTr ) { transform = theTr; } const BoundingBoxS getBoundingBox () const { । return boundingBox; } void animate ( const Transform3DS tr ) { curTransform = tr * curTransform; } void bool addAnimator ( removeAnimator ( Animator * theAnimator ) ; const Strings name ); virtual void update ( Controller * controller, float curTime ); virtual void draw ( Views view, const Cameras camera, const FrustrumS frustrum, Fog * fog ); virtual bool isTransparent () const { return false; } static MetaClass classinstance; protected: virtual void buildBoundingBox (); virtual void doDraw ( Views view, const Cameras camera, const FrustrumS frustrum, Fog * fog ) {} virtual virtual } ; void void preDraw postDraw ( Views view ); ( Views view ); Каждый такой объект имеет положение в пространстве (pos), базовый цвет (color) и преобразование (transform), применяемое перед выводом объекта. Также удобно сразу добавить ограничивающее тело (ААВВ).
Здесь метод draw служит для рисования объекта с заданными параметрами - объектом, в который осуществляется рисование, камерой, областью видимости и туманом. Метод update служит для обновления состояния объекта и получает на вход ссылку на контроллер и на время, прошедшее с момента запуска программы. Метод isTransparent служит для определения прозрачности объекта, поскольку прозрачные объекты должны выводиться в определенном порядке. Защищенные (protected) методы doDraw, preDraw и postDraw реализуют основные этапы рендеринга объекта. Эти методы и сам метод draw выглядят следующим образом: S! void VisualObject :: draw ( View& view, const Camera& camera, const Frustrum& frustrum, Fog * fog ) { preDraw ( view ); doDraw ( view, camera, frustrum ,fog ); postDraw ( view ); } void VisualObject :: preDraw ( View& view ) { if ( saveFlags ) glPushAttrib ( saveFlags ); glMatrixMode ( GL_MODELVIEW ); glPushMatrix (); glTranslatef ( pos.x, pos.y, pos.z ); view.apply ( curTransform ); } void VisualObject :: postDraw ( View& view ) { glPopMatrix (); if ( saveFlags ) glPopAttrib (); } Как видно из приведенного кода, метод preDraw осуществляет сохранение состояния OpenGL, после чего применяет необходимые преобразования. Метод postDraw служит для восстановления состояния OpenGL
по завершении вывода. Для задания того, какие именно параметры OpenGL требуют сохранения, используется переменная saveFlags, задающая биты состояния OpenGL. Для поддержки таких объектов необходимо ввести определенные изменения в классах SubScene и StencilSubScene, а именно добавить массив неполигональных объектов, методы по их добавлению и удалению, а также их рисование и анимацию. В случае полупрозрачных объектов VisualObject (систем частиц, полупрозрачных моделей и т. п.) и полупрозрачных граней возникает вопрос об их правильном взаимном упорядочении. Заметим, что метод, используемый для упорядочения полупрозрачных граней, здесь малоприменим, поскольку в общем случае по объекту VisualObject нельзя построить плоскость, а разбивать его вдоль какой-либо плоскости также не всегда удобно. Ниже предлагается упрощенная модель, когда каждый такой объект представляется как точка. Тогда их можно упорядочить между собой и по отношению к BSP-дереву, построенному по полупрозрачным граням и "плавающим" порталам и зеркалам. Однако такой подход в некоторых сценах может работать некорректно. Э void StencilSubScene :: render ( View& view, const Cameras camera, const Frustrum& viewFrustrum, Array& post ) const { Polygon3D tempPoly ( "tempPoly", MAX_VERTICES ); view.lock (); view.apply ( camera ); int sTest = gllsEnabled ( GL_STENCIL_TEST ); setDefaultStencilOpAndFunc (); // render opaque polygons, clipping them // via stencil buffer for ( Array :: Iterator it = opaqueFaces.getlterator (); !it.end (); ++it ) { Polygon3D * poly = (Polygon3D *) it.value (); if ( !poly -> isFrontFacing ( camera.getPos () ) ) continue;
if ( poly -> testFlag ( PF_PORTAL ) ) continue; // if not outside of viewing frustrum then // draw it if ( viewFrustrum.contains ( poly -> getBoundingBox () ) ) renderPoly ( view, camera, poly, tempPoly, viewFrustrum, post ); } i f ( sky 1= NULL ) sky -> draw ( camera ); // now render portals and mirrors for ( it = opaqueFaces.getlterator (); lit.end (); ++it ) { Polygon3D * poly = (Polygon3D *) it.value (); if ( Ipoly -> isFrontFacing ( camera.getPos () ) ) continue; if ( ipoly -> testFlag ( PF_PORTAL ) ) continue; tempPoly = *poly; // copy current poly to temp poly // clip against view frustrum if ( ItempPoly.clipByFrustrum ( viewFrustrum ) ) continue; // now draw portal to stencil using inc op drawToStencil ( tempPoly ); curStencilVal++; setDefaultStencilOpAndFunc (); // for floater clear depth if ( poly -> testFlag ( PF_FLOATING ) ) clearDepth ( camera, tempPoly ); // now render adjacent subscene renderPoly ( view, camera, poly, tempPoly, viewFrustrum, post, ob );
// for floaters and mirrors set depth // to that of portal if ( poly -> testFlag (PF_FLOATING) || poly -> testFlag (PF_MIRROR) ) setDepth ( tempPoly ); // restore stencil using dec op restorestencil ( tempPoly ); curStencilVal--; setDefaultStencilOpAndFunc (); } // now render opaque objects for ( it = objects.getlterator (); lit.end (); ++it ) { VisualObject * object = (VisualObject *) it.value (); if ( object -> isTransparent () ) continue; if ( viewFrustrum.contains ( object -> getBoundingBox () ) ) object -> draw ( view, camera, viewFrustrum, fog ) ; } // now render transparent polygons BspRenderlnfo info; ObjectSortlnfo transpObjects [MAX_TRANSPARENT_OBJECTS]; int count = 0; info.view = &view; info.viewFrustrum = &viewFrustrum; info.camera = ^camera; info.tempPoly = &tempPoly; info.post = &post; // build list of transparent objects for ( it = objects.getlterator (); I it.end (); ++it ) { VisualObject * object = (VisualObject *) it.value (); if ( object -> isTransparent () && count < MAX_TRANSPARENT_OBJECTS ) {
transpObjects[count ].кеу= object->getPos() & camera.getViewDir (); transpObjects[count++].object = object; } } // now sort it qsort ( transpObjects, count, sizeof ( ObjectSortlnfo ), objectCompFunc ); // render tree and all transparent objects renderTree ( root, info, transpObjects, count ); processLights ( camera, viewFrustrum, post ); view.unlock (); // commit drawing if ( IsTest ) glDisable ( GL_STENCIL_TEST ); void StencilSubScene :: renderTree ( BspNode * node, BspRenderInfo& info, ObjectSortlnfo list [], int count ) const if ( node == NULL ) { for ( int i = 0; i < count; i++ ) list[i].object->draw(*info.view, *info.camera, *info.viewFrustrum, fog ); return; } // build list of front and back objects ObjectSortlnfo front [MAX_TRANSPARENT__OBJECTS]; ObjectSortlnfo back [MAX_TRANSPARENT_OBJECTS]; int frontcount = 0; int backCount = 0; for ( int i = 0; i < count; i++ ) if ( node -> classify ( list [i].object -> getPos () ) == IN_FRONT ) front [frontCount++] = list [i]; else back [backCount++] = list [i] ;
// now render the tree if ( node -> classify ( info.camera -> getPos () ) == IN„FRONT ) { renderTree ( node -> right, info, back, backCount ); if ( node -> facet -> isFrontFacing ( info.camera -> getPos () ) ) renderPoly ( *info.view, *info.camera, node -> facet, *info.tempPoly, *info.viewFrustrum, *info.post ); renderTree ( node -> left, info, front, frontCount ); } else { renderTree ( node -> left, info, front, frontCount ); if ( node -> facet -> isFrontFacing ( info.camera -> getPos () ) ). renderPoly ( *info.view, *info.camera, node -> facet, *infо.tempPoly, *infо.viewFrustrum, *info.post ); renderTree ( node -> right, info, back, backCount ); } } Как видно из приведенного кода, сначала осуществляется вывод всех непрозрачных объектов (как граней, так и экземпляров класса VisualObject) и строится список всех полупрозрачных объектов VisualObject. После этого осуществляется рекурсивный рендеринг дерева вместе с полупрозрачными объектами. Основное отличие от стандартной процедуры рисования BSP-дерева заключается в том, что на каждом шаге текущий список объектов разбивается на две части, в зависимости от положения каждого объекта по отношению к разбивающей плоскости. Построенные, списки передаются дальше каждому из соответствующих поддеревьев. Если поддерево пусто, то переданные объекты выводятся в произвольном порядке. Ниже приводится описание класса Billboard для работы с панелями. S! class Billboard : public VisualObject { protected: Texture * texture; float width, height;
public: Billboard ( const char * theName, const Vector3D& thePos, const Vector4D& theColor, Texture * theTexture, float theWidth, float theHeight ); --Billboard (); float getWidth () const { return width; } float getHeight () const { return height; } Texture * getTexture () const { return texture; } virtual void draw ( View& view, const Cameras camera, const Frustrum& frustrum, Fog * fog ); static MetaClass classinstance; }; Обратите внимание, что в данном классе определяется сам метод draw, а не doDraw. Для задания панели будем использовать следующую команду: billboard name { texture texture-name size sizeCoeff pos (x, y, z) } Данные параметры задают используемую текстуру, размер панели и ее положение в комнате.
Системы частиц Еще одним достаточно простым эффектом являются системы частиц. | Системы частиц представляют собой динамические системы достаточно | простых объектов, представленных обычно в виде панелей. Несмотря на то | что закон, описывающий всю систему, достаточно прост, с помощью такой j системы легко можно моделировать целый ряд сложных эффектов, включая дым, огонь, взрывы и т. п. Таким образом, система частиц является частным случаем класса Visu- I, alObject. Система частиц состоит из постоянно обновляемого и анимируемого набора элементарных частиц. Каждая такая частица имеет положение в пространстве, скорость, массу, цвет, размер и текстуру. Поэтому удобно представлять отдельные частицы в виде экземпляров следующей структуры: S struct Particle { Particle Particle * next; // next previ particle in the list * prev; // .Ous particle in the list Vector3D Vector3D float Vector4D float float Texture float // // POS; // velocity; // mass; // color; // size; // energy; * texture; timeOfBirth;// Physical & visual of the particle it's position it's velocity it's mass it's color it's size time the particle parameters was born float lifeTime; // time the particle will live float lastUpdateTime; // time the particle was last updated // check whether the particle should be killed bool isAlive ( float curTime ) const { return (timeOfBirth + lifeTime >= curTime) && (size >= EPS) && (energy >= EPS); } void kill () // force particle to be killed // during next update
{ lifeTime = -1; } } ; При этом структура Particle содержит все необходимые атрибуты, присущие любой частице. Поля prev и next позволяют связать все частицы одной системы в двусвязанный список. Поле pos содержит текущее положение частицы, а поле velocity - ее скорость. Поле mass содержит значение массы данной частицы, а поле color - текущее значение ее цвета. Текущий размер частицы содержится в поле size. Также с каждой частицей связывается текстура texture. Для удобства анимации с каждой частицей связывается время ее рождения (timeOfBirth), продолжительность жизни частицы (lifeTime) и время, когда состояние частицы последний раз было изменено (lastUpdateTime). Метод isAlive служит для определения необходимости уничтожения частицы в связи с истечением ее срока жизни, а метод kill помечают частицу для уничтожения. При необходимости работы с частицами, содержащими дополнительные атрибуты, можно строить новые структуры, унаследованные от структуры Particle. Класс ParticleSystem представляет собой базовый класс для создания различных типов систем частиц и работы с ними; ниже приводится его описание. га <OL class ParticleSystem : public VisualObject ( protected: int particleSizelnBytes; MemoryPool * pool; // pool used to allocate/free particles Particle * start; // first particle in the list int numParticles; float lastCreationTime; // time the last particle was created float birthPeriod; // period of particle creation int srcBlendingMode; // OpenGL blending modes int dstBlendingMode;
publi С: ParticleSystem ( const char * theName, const Vector3DS thePos, int particlesPerSecond ); -ParticleSystem (); virtual int init (); virtual void update ( Controller * controller, float curTime ); virtual { bool isTransparent () const return true; // assume all particle system: // are semitransparent } void insert ( [ Particle * ) ; void remove i [ Particle * ) ; void { setBlendingMode ( int src, int dst srcBlendingMode = src; dstBlendingMode = dst; } intgetNumParticles () const { return numParticles; } static MetaClass classlnstance; protected: virtual void doDraw ( Views view, const Cameras camera, const FrustrumS frustrum, Fog * fog ); virtual void createParticle ( float curTime ) = 0; void updateBoundingBox (); }; Как видно из описания, каждая система частиц содержит размер структуры, используемой для описания частиц в переменной particleSizelnBytes. Использование этой переменной позволяет дочерним классам добавлять до
полнительные атрибуты к частицам. При этом необходимо, чтобы структура, используемая для описания таких частиц, была унаследована от структуры Particle и чтобы можно было выставить величину particleSizelnBytes, равной размеру этой структуры. При соблюдении этих правил система будет нормально функционировать. Для выделения памяти под частицы используется пул, а не конструкция new, что объясняется соображениями эффективности, поскольку создание и уничтожение отдельных частиц будет происходить очень часто, а операции new и delete достаточно дорогостоящие. Также в системе хранится общее число частиц (numParticles), ссылка на первую частицу в списке (start), время, когда последний раз была создана частица (lastCreationTime), средний интервал времени между созданием новых частиц (birthPeriod) и режим наложения (srcBledningMode и dstBlend-ingMode). Метод doDraw служит для отрисовки системы, и дочерние классы, как правило, могут использовать его без переопределения. Он просто задает необходимый режим наложения и выводит все частицы системы как панели. g void ParticleSystem :: doDraw ( View& view, const Camera& camera, const Frustrum& frustrum, Fog * fog ) { glDepthMask ( GL_FALSE ); view.blendFunc ( srcBlendingMode, dstBlendingMode ); for ( Particle * cur = start; cur != NULL; cur = cur -> next ) view.drawBillboard ( cur -> pos, cur -> size, color * cur -> color, cur -> texture ); glDepthMask ( GL_TRUE ); } Также в классе ParticleSyste определяется метод update. Этот метод выполняет следующие функции: он создает новые частицы и удаляет те частицы, время жизни которых закончилось. Подклассы должны переопределить данный метод, если они хотят добавить какую-либо динамику частицам, но при этом переопределенный метод должен вызывать ParticleSys-tem::update для создания новых и уничтожения старых частиц.
void ParticleSystem :: update ( Controller * controller, float curTime ) { VisualObject :: update ( controller, curTime ),- // remove dead particles for ( register Particle * cur = start; cur != NULL; ) { register Particle * next = cur -> next; // save next particle (if cur will be deleted) if ( !cur -> isAlive ( curTime ) ) remove ( cur ); cur = next; } if ( birthperiod < 0 ) // for systems where all particles are created // at start return; // create new particles if ( lastCreationTime + birthPeriod < curTime ) { for ( float time = lastCreationTime; time + birthPeriod < curTime; time += birthPeriod ) createParticle ( time ); lastCreationTime = curTime; } } Одной из простейших систем частиц является фонтан - частицы вылетают из источника в заданном направлении с контролируемыми случайными отклонениями и после этого движутся под действием силы тяжести, время жизни частицы ограничено. Ниже приводится описание этого класса. К1 class Fountain : public ParticleSystem { private: float lastUpdateTime; // time the system was last // updated or -1
Vector3D shootDir; // shooting direction Vector3D gravity; // gravity acceleration Vector3D wind ,- // // vector wind direction and speed float dispersion; // measure of randomness float Texture Vect6r4D public: Fountain lifeTime; k texture; color; ( const char 51 * theName, const Vector3D& thePos, int particlesPerSecond, Texture * theTexture, const Vector3D& theShootDir, const Vector3D& theGravity, const Vector3D& theWind, float theDispersion, float theLifeTime, const Vector4D& theColor ) : ParticleSystem ( theName, thePos, particlesPerSecond ) { shootDir = theShootDir; gravity = theGravity; wind = theWind; dispersion = theDispersion; lifeTime = theLifeTime; lastUpdateTime = -l.Of; texture = theTexture; color = theColor; metaClass = &classlnstance; } virtual void update ( Controller * controller, float curTime ); static MetaClass classinstance; protected: virtual void createParticle ( float curTime ); } ; Приведем пример задания фонтана в sc-файле. fountain f { pos ( 12, 1, 16 ) texture ". .\\TexturesWelectricprtl.bmp" particles-per-second 100 dispersion 0.05 life-time 30
dir ( 0, 0.2, 0 ) color ( 0.2, 1, 0, 1 ) } Еще одним интересным примером системы частиц является огонь. В нем новые частицы создаются в окрестности заданной точки и движутся вверх, постепенно изменяя свой цвет. Для простоты будем считать, что цвет изменяется линейно от начального значения до конечного с течением времени. S class Fire : public ParticleSystem { private: Texture * texture; float startRadius; float speed; Vector4D startcolor; Vector4D endcolor; float lastUpdateTime; float lifeTime; float dispersion; float minSize; float maxsize; public: Fire ( const char * theName, const Vector3D& thePos, int particlesPerSecond, float theLifeTime, float radius, float theSpeed, Texture * theTexture, const Vector4D& color1, const Vector4D& color2 ),- -Fire () ,- virtual void update ( Controller * controller, float curTime ); static MetaClass classinstance; protected: virtual void createParticle ( float curTime ); void setParticleColor ( Particle * cur, float curTime ) { float t = (curTime - cur -> timeOfBirth) / lifeTime; cur -> color = startcolor * ( 1 - t ) + endcolor * t; }
S? void Fire :: update ( Controller * controller, float curTime ) { if ( lastUpdateTime < 0 ) lastUpdateTime = curTime; float delta = curTime - lastUpdateTime; ParticleSystem :: update ( controller, curTime ); for ( Particle * cur = start; cur != NULL; ) { float delta = curTime - cur -> lastUpdateTime; cur -> pos += cur -> velocity * (speed * delta); cur -> velocity.x *= 0.9f; cur -> velocity.z *= 0.9f; setParticleColor ( cur, curTime ); cur = cur -> next; } updateBoundingBox (); lastUpdateTime = curTime; } Пример задания достаточно реалистически выглядящего огня также приводится ниже. Н fire f { pos ( 0, 0.7, 2 ) texture "../Textures/Flares/f1аге32.tga" particles-per-second 300 life-time 0.2 radius 0.001 speed 0.13 src-color ( 1, 1, 0.2, 1 ) dst-color ( 0.5, 0, 0, 0.25 ) На компакт-диске содержатся реализации еще двух систем частиц -Snow и MagicSphere.
Хало, блики на линзах Последние два из рассматриваемых эффектов связаны с источниками света. Яркий источник света, во-первых, вызывает появление бликов на линзах объектива (lens flare) и, во-вторых, гало (halo), когда яркие пикселы, соответствующие проекции источника света, как бы "размываются", что приводит к увеличению яркости соседних пикселов. При работе с источниками света мы будем опираться на материал гл. 10, в частности мы будем использовать метод pointVisibleFrom для определения видимости источника света из положения наблюдателя. Заметим, что если источник света невидим, то никаких эффектов он не производит и может быть полностью проигнорирован. В случае же, когда источник света виден, то для него можно нарисовать и гало и блики (что именно, будет определяться атрибутами источника). Пусть источник света проектируется в точку (Рх, Ру') на экране (для нахождения этой точки удобно воспользоваться методом mapToScreen класса Camera). Тогда достаточно вывести с центром в этой точке текстуру, представляющую собой размытое пятно соответствующего цвета с режимом наложения, соответствующим сложению цветов (GL_ONE, GL_ONE). Работа с бликами на линзах несколько сложнее, но также может быть легко реализована. Пусть (Рх, Ру) , как и ранее, экранные координаты проекции видимого источника света, а (Сх, Су) - координаты центра экрана. Тогда на отрезке, соединяющем эти две точки, выводятся несколько промасштабированных изображений бликов света. В табл. 12.1 ниже приводятся варианты расположения бликов на этом отрезке. Таблица 12.1 м Номер текстуры Сдвиг Масштаб 0 0 1 1 1 1 0.5 0.5 2 2 0.33 0.25 3 3 0.1225 1 4 2 -0.5 0.5 5 3 -0.25 0.25 6 2 -0.18 0.25
Обратите внимание, что и гало и блики на линзах должны выводиться после отрисовки всей сцены. Если их рисовать сразу при выводе очередной комнаты, то они могут закрываться гранями и объектами, что будет неправильно. Поэтому оба этих эффекта должны обрабатываться уже на этапе постобработки. Для их поддержки мы введем в метод render список, в который каждая комната может добавить видимые источники постэффектов. После отрисовки всей сцены метод render класса World выводит пост-эффекты. Ниже приводится метод processLights класса SubScene, служащий для добавления видимых источников света в список постэффектов. void SubScene :: processLights ( const Cameras camera, const FrustrumS viewFrustrum, Arrays post ) const { World * world = (World *) getOwner (}; for ( Array :: Iterator it = objects.getlterator (); !it.end (); ++it ) ( Light * light = dynamic_cast <Light *> ( it.value () ); if ( light == NULL ) continue; // it's a light source check for effective // distance of light source if ( camera.getPos ().distanceTo ( light -> getPos () ) >= light -> getRadius () ) continue; // now check whether it lies within // view frustrum if ( !viewFrustrum.contains ( light -> getPos () ) ) continue; // trace ray to light to see if // it's visible if ( world -> pointVisibleFrom ( light -> getPos(), camera.getPos() ) ) post.insert ( light ); } } Для вывода этих эффектов используется следующий метод:
struct FlareDesc { int index; // texture index float lengthscale; float imagescale; static FlareDesc flareTable [7] = { { 0, 1, 1 }, // primary flare { 1, 0.5f, 0.5f } , // first halo { 2, 0.33f, 0.25f }, // small birst { 3, 0.125f, 1 } , // next halo { 2, -0.5f, 0.5f, }, // next birst { 3, -0.25f, 0.25f }, // next halo { 2, -0.18f, 0.25f } // next birst void World :: drawPostObjects ( View& view, const Cameras camera, const Arrays post ) const { float haloSize = 20; float flareSize = 30; // save current state glPushAttrib ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_ENABLE_BIT | GL_STENCIL_BUFFER_BIT ); glEnable ( GL_BLEND ); glDisable ( GL_DEPTH_TEST ); glDepthMask ( GL_FALSE ) ; // setup drawing mode view.startOrtho (); view.blendFunc ( View :: bmSrcAlpha, View :: bmOne ); for ( Array :: Iterator it = post.getlterator (); !it.end (); ++it ) { Light * light = dynamic_cast <Light *> ( it.value () }; if ( light == NULL ) continue; // it's a light source, now // get screen coordinates of it
Vector3D loc ( camera.mapToScreen ( light -> getPos () ) ),-float dist=camera.getPos ().distanceTo ( light -> getPos () }; /I draw the halo if ( light -> testFlag ( LF_HAS_HALO ) ) { Vector4D color ( light -> getColor ()/(!+ 0.3f * dist ) ) ; color.w = 0.4f; // make it not too bright draw2dRect ( view, loc, Vector2D ( haloSize, halosize ), color, haloTexture ); } if ( light -> testFlag ( LF_HAS_LENS_FLARE ) ) { Vector3D c (view.getWidth() * 0.5f, view.getHeight() * 0.5f, 1); Vector3D v (c - loc); for ( int i = 0; i < sizeof ( flareTable ) / sizeof ( flareTable [0] ) ; i++ ) { Vector3D p (c + flareTable [i].lengthscale * v ); Vector4D color (light -> getColor () ) ; float size (flareSize * flareTable [i].imageScale ); color.w = 0.6f; // make it not too bright draw2dRect ( view, p, Vector2D ( size, size ), color, lensFlareTexture [flareTable [i].index] ); } } view. endOrtho (); glPopAttrib ();
Глава 13. ДОБАВЛЯЕМ МОДЕЛИ Модели Помимо статической геометрии, т. е. набора многоугольников, задающих стены, пол, лестницы и т. п., часто возникает необходимость работы с целыми группами граней, объединенных в один объект. Таким объектом может быть ваза с цветами на столе (как, впрочем, и сам стол), оружие, противники и т. п. Во всех этих случаях возникает необходимость в объектах, представленных набором большого числа небольших граней (обычно треугольников). При этом удобно иметь обычный набор вершин, цветов и текстурных координат, а также считать, что векторы нормали задаются в вершинах, а не на гранях. Это еще удобно и потому, что OpenGL поддерживает освещение, также используя значения нормали в вершинах. Использование объектов, заданных таким образом, также удобно и тем, что стандартный OpenGL поддерживает так называемые вершинные массивы (vertex arrays), позволяющие не передавать по очереди все значения для каждой из вершин, а просто указать ссылки на массивы значений, из которых следует взять соответствующие значения. Подобный подход, позволяющий всего за несколько вызовов построить сложный объект, состоящий из сотен и даже тысяч граней, очень удобен и дает большое преимущество в скорости по сравнению с передачей каждого значения отдельным вызовом OpenGL. Таким образом, каждый такой объект (их часто называют моделями, meshes), содержит в себе массив вершин, цветов, текстурных координат и векторов нормали. Также подобный объект может содержать в себе ссылку на используемую текстуру, ограничивающее тело и описания граней. Обычно считается, что каждая грань является треугольником, задаваемым тремя индексами в массив вершин. Для поддержки таких объектов мы будем использовать следующий класс: Н class Mesh3D : public Object { public: struct Face // triangular face
{ . int vertexindex ГЗ] ; // indexes to array of vertices int texCoordlndex [3 J; // indexes to array of texture coordinates } ; struct FaceVa // triangular face struct for // usage with vertex arrays ( int }; index [3]; private: struct FaceOrderinglnfo // struct used to keep info for // ordering transparent meshes { float key; int index; } ; // distance // facet # int numvertices; int numTexCoords; int numFaces; Vector3D * vertices; Vector3D * normals; Vector2D * texCoords; Vector4D * colors; FaceVa * faces; Texture * texture; BoundingBox boundingBox; Bool smooth; public: Mesh3D Mesh3D ( const char * theName = "” ); ( const char * theName, Vector3D * theVertices, Vector3D * theNormals, int theNumVertices, Vector2D * theTexCoords, int theNumTexCoords, Face * theFaces, int theNumFaces, Texture * theTexture, Vector4D * theColors = NULL ); ~Mesh3D (};
virtual bool isOk () const { return vertices != NULL && faces 1= NULL; } const BoundingBox& getBoundingBox () const { return boundingBox; } const Vector3D * getVertices () const { return vertices; } int getNumVertices () const { return numvertices; } void setTexture ( Texture * theTexture ); void draw ( Views view, const Cameras camera, const Vector4DS color, Fog Texture * txt = NULL, * fog. bool transparent = false ) const; void apply ( const Transform3DS ); void computeNormals (); void buildBoundingBox (); static MetaClass classinstance; protected: void rebuildFaces ( Face * theFaces, Vector2D * theTexCoords, int nTexCoords ); ) ; Метод computeNormals служит для вычисления значений нормалей в вершинах модели. Для этого сперва вычисляются значения вектора нормали для каждой грани модели (как векторное произведение ребер), а затем для каждой вершины находится сумма нормалей граней, проходящих через эту вершину, и значением нормали в вершине становится эта нормированная сумма.
void Mesh3D :: computeNormals () { Vector3D normal, v [3] ; if ( normals != NULL ) // reallocate normals delete normals; normals = new Vector3D [numVertices] ; // face normals Vector3D * tempNormals = new Vector3D [numFaces]; // process all faces of mesh for ( int i = 0; i < numFaces; i++ ) { // extract the 3 points of this face v [0] = vertices [faces [i].index [0]]; v [1] = vertices [faces [i].index [1]]; v [2] = vertices [faces [i].index [2]]; // compute face normal normal = (v [2] - v [0]) л (v [2] - v [1]); tempNormals [i] = normal.normalize (); // now compute vertex normals Vector3D sum (0, 0, 0 ); Vector3D zero ( sum ); for ( i { for { 0; i < numVertices; i++ ) // process each vertex in turn int j =0; j < numFaces; j++ ) // pocess each face // check if the vertex is shared //by another face if ( faces [j].index [0] == i || faces [j].index [1] == i || faces [j].index [2] == i ) sum += tempNormals [j];
normals [i] = sum.normalize (); sum = zero; // reset the sum } delete tempNormals; // free face normals ) В простейшем случае вывод всех граней может быть осуществлен при помощи следующего фрагмента кода: void Mesh3D :: draw ( View& view, const Camera& camera, const Vector4D& color, Fog * fog. Texture * txt, bool transparent ) const ( if ( lisOk () ) return; int shadeModel; if ( txt != NULL ) view.bindTexture ( txt ); else view.bindTexture ( texture ); glGetlntegerv ( GL_SHADE_MODEL, &shadeModel ); glColor4fv ( color ); if ( smooth ) glShadeModel ( GL_SMOOTH ); else glShadeModel ( GL_FLAT ); glEnableClientState ( GL_VERTEX_ARRAY ); glVertexPointer ( 3, GL_FLOAT, 0, vertices ); if ( colors != NULL ) ( glColorPointer ( 4, GL_FLOAT, 0, colors ); glEnableClientState ( GL_COLOR. ARRAY ); } else glDisableClientState ( GL„COLOR_ARRAY-•);
if ( normals != NULL ) { glNormalPointer ( GL_FLOAT, 0, normals ); glEnableClientState ( GL_NORMAL_ARRAY ); } else glDisableClientState ( GL_NORMAL_ARRAY ); if ( texCoords != NULL ) { glTexCoordPointer ( 2, GL_FLOAT, 0, texCoords ); glEnableClientState ( GL_TEXTURE_COORD_ARRAY ); } else glDisableClientState ( GL_TEXTURE_COORD_ARRAY ); glDrawElements ( GL_TRIANGLES, 3*numFaces, GL_UNSIGNED_INT, faces ) ; glDisableClientState ( glDisableClientState ( glDisableClientState ( glDisableClientState ( GL_VERTEX_.ARRAY ) ; GL_COLOR_ARRAY ); GL_NORMAL_ARRAY ) ; GL_TEXTURE_COORD_ARRAY ); glShadeModel ( shadeModel ); } Здесь используются функции OpenGL для работы с вершинными массивами. Однако если объект уже имеет собственный массив цветов в вершинах, и переданный цвет отличается от (1, 1, 1, 1), то мы уже не можем использовать массив colors в том виде, в каком он есть. Но мы также не можем умножить его на переданное значение цвета, поскольку это значение может со временем измениться (например, за счет анимации), и тогда нам понадобится восстановить исходный массив. Поэтому удобнее всего будет завести вспомогательный массив (tempColors), и в случае, если массив цветов для модели задан и переданный цвет отличается от (I, 1, 1, 1), мы можем занести в этот массив умноженные значения цветов для вершин и передать его в функцию glColorPointer. Чтобы не делать это умножение каждый раз, удобно использовать кеширование: при умножении запоминается, на какое значение был умножен вспомогательный массив, и проверять, можно ли использовать его или надо произвести его построение заново. Это значение мы будем запоминать в переменной cachedColor.
Реализация этого подхода приводится ниже. S? void Mesh3D :: draw ( View& view, const Camera& camera, const Vector4D& color, Fog * fog, Texture * txt, bool transparent ) const { if ( !isOk () ) return,- int shadeModel; Vector4D * colorArray = colors; if ( txt != NULL ) view.bindTexture ( txt ); else view.bindTexture ( texture ); glGetlntegerv ( GL_SHADE_MODEL, &shadeModel ); glColor4fv ( color ); if ( smooth ) glShadeModel ( GL_SMOOTH ); else glShadeModel ( GL_FLAT ) ; // check whether we need to update tempColors array if ( colors != NULL && ( color.x !=1 || color.у != 1 || color.z != -1 || color.w != 1 || color != cachedColor ) ) { for ( int i = 0; i < numVertices; i++ ) tempColors [i] = color * colors [i] ; cachedColor = color; colorArray = tempColors; } glEnableClientState ( GL_VERTEX_ARRAY ); glVertexPointer ( 3, GL_FLOAT, 0, vertices ); if ( colors != NULL ) { glColorPointer ( 4, GL_FLOAT, 0, colorArray ); glEnableClientState ( GL_COLOR_ARRAY );
else glDisableClientState ( GL„COLOR_ARRAY ); if ( normals != NULL ) { glNormalPointer ( GL_FLOAT, 0, normals ); glEnableClientState ( GL_NORMAL_ARRAY ); J else glDisableClientState ( GL__NORMAL_ARRAY ); if ( texCoords != NULL ) { glTexCoordPointer ( 2 ,GL_FLOAT, 0, texCoords ); glEnableClientState ( GL_TEXTURE_COORD_ARRAY ); } else glDisableClientState ( GL_TEXTURE_COORD_ARRAY ); glDrawElements ( GL_TRIANGLES, 3*numFaces, GL_UNSIGNED_INT, faces ); glDisableClientState ( GL_VERTEX_ARRAY ); glDisableClientState ( GL_COLOR_ARRAY ); glDisableClientState ( GL_NORMAL_ARRAY ); glDisableClientState ( GL_TEXTURE_COORD_ARRAY ); glShadeModel ( shadeModel ); } Если мы хотим добавить поддержку тумана, т. е. ситуации, когда объект находится в тумане, может быть даже частично, то необходимо вычислить значение цвета в вершинах с учетом затуманивания. Для этого удобно опять использовать массив tempColors. Реализация приводится ниже. S if ( fog != NULL ) // compute fogging fore vertices, // using tempColors array { for ( int i = 0;i < numVertices; i++ ) tempColors [i] = fog -> getBlendColor ( camera.getPos (), vertices [i], NULL ); cachedColor = Vector4D ( -1, -1, -1, -1 ); colorArray = tempColors; }
Наконец, еще одной возможностью, которую хотелось бы добавить, является поддержка полупрозрачных объектов. Для правильной отрисовки таких объектов их грани должны выводиться в порядке приближения к наблюдателю (back-to-front). Для этого удобно отсортировать грани по расстоянию от середины грани до наблюдателя (камеры) вдоль направления вектора взгляда. Для реализации этого удобно ввести еще один вспомогательный массив, где для каждой грани будет храниться ее номер и. значение ключа сортировки. Перед выводом модели массив заполняется значениями ключей сортировки и сортируется функцией qsort. После этого грани выводятся в порядке их следования в уже отсортированном массиве. Чтобы не вычислять значения середин граней каждый раз, удобно их также хранить в отдельном массиве (centers'). Версия класса Mesh3D с учетом всех этим изменений приводится ниже. К1 class Mesh3D : public Object ( public: struct Face // triangular face ( int vertexindex [3]; // indexes to array of vertices int texCoordlndex [3] ; // indexes to array of texture coordinates }; struct FaceVa // triangular face struct for // usage with vertex arrays { int index [3]; }; private: struct FaceOrderinglnfo // struct used to keep info { // for ordering // meshes transparent float key; // distance int index; // facet # };
int numVertices; int numTexCoords; int numFaces; Vector3D * vertices; Vector3D * normals; Vector2D * texCoords; Vector4D * colors; FaceVa * faces; Texture * texture; BoundingBox boundingBox; Bool smooth; FaceOrderinglnfo * indices; Vector3D * centers; // faces center poins Vector4D * tempColors; // temp array for color values when in // call to draw color is not ( 1, 1, 1, ) and // colors array is present mutable Vector4D cachedColor; // color for which tempColors is // computed, declared mutable since it // can be modified in const method draw public: Mesh3D Mesh3D ( const char * theName = "" ); ( const char * theName, Vector3D * theVertices, Vector3D * theNormals, int theNumVertices, Vector2D * theTexCoords, int theNumTexCoords, Face * theFaces, int theNumFaces, Texture * theTexture, Vector4D * theColors = NULL ); -Mesh3D (); virtual bool isOk () const { return vertices != NULL && faces != NULL; } const BoundingBox& getBoundingBox () const ( return boundingBox; } const Vector3D * getVertices () const { return vertices; }
intgetNumVertices () const { return numvertices; } void setsmooth ( bool flag ) { smooth = flag; } bool getSmooth () const { return smooth; } void setTexture ( Texture * theTexture ); void draw ( Views view, const Cameras camera, const Vector4DS color, Fog * fog, Texture * txt = NULL, bool transparent = false ) const; void apply ( const Transform3Ds ); void computeNormals (); void buildBoundingBox (); static MetaClass classinstance; protected: void rebuildFaces ( Face * theFaces, Vector2D * theTexCoords, int nTexCoords ); static int _____cdecl compFunc ( const void * elemi, const void * elem2 ); }; Удобно "завернуть" объект класса Mesh3D в класс VisualObject. Это позволит не только применять различные преобразования к этому объекту, изменять его цвет, но также и разделять экземпляры класса Mesh3D между разными объектами, т. е. если в сцене встречается много экземпляров одного и того же объекта Mesh3D, то сам этот объект будет храниться всего один раз и только "оборачивающие" его объекты VisualObject будут разными. Поскольку в сцене часто встречается много экземпляром одинаковых моделей, то получается несомненный выигрыш по памяти. Ниже приводится описание класса MeshObject, служащего для инкапсуляции модели внутри себя.
class MeshObject : public VisualObject { protected: Mesh3D * mesh; public: MeshObject ( const char * theName, const Vector3DS thePos, const Vector4DS theColor, const Transform3DS theTr, Mesh3D * theMesh = NULL ); -MeshObject (); Mesh3D * getMesh () const { return mesh; } void setMesh ( Mesh3D * theMesh ); virtual bool isTransparent () const; static MetaClass classinstance; protected: virtual void buildBoundingBox (); virtual void doDraw ( Views view, const Cameras camera, const Frustrums frustrum. Fog * fog ); }; Метод doDraw этого класса выглядит следующим образом: Н void MeshObject : : draw ( Views view, const Cameras camera, const Frustrums frustrum, Fog * fog ) { mesh -> draw ( view, camera, color, fog ); } Введем теперь в объекты VisualObject поддержку простейшей анимации, которая может происходить полностью автоматически, без учета наблюдателя. Для этого предлагается использовать следующий класс:
га class Animator : public Object { public: Animator ( const char * theName ) : Object ( theName ) { metaClass = &classlnstance; } virtual void animate ( VisualObject * object, float curTime ) {} static MetaClass classinstance; } ; Задачей метода animate является изменение текущего преобразования объекта со временем для получения анимации. При этом необходимо соответствующим образом модифицировать класс VisualObject для поддержки аниматоров. Во-первых, нужно ввести массив аниматоров, которые будут последовательно применяться к объекту (точнее, к его текущему преобразованию). Во-вторых, удобно хранить два преобразования: одно (prigTrans-fonri) начальное, заданное при создании объекта, а другое (curTransform), получаемое из origTransform после применения всех аниматоров. Э class VisualObject : public Object { protected: Vector3D pos; // position of the object Vector4D color; // color to modulate object Transform3D origTransform; // original transform II applied to objects и (before animators) Transform3D curTrans form;: 11 current transform (build И from original by // animators) Array animators; BoundingBox boundingBox; u bounding box of object }; Тогда в объект VisualObject можно добавить ссылку на соответствующий аниматор и его вызов из метода update.
void VisualObject :: update ( Controller * controller, float curTime ) { curTransform = transform; // reset transform // let every animator to II modify curTransform for ( Array :: Iterator it = animators.getlterator (); !it.end (); ++it ) { Animator * animator = (Animator *) it.value (); animator -> animate ( this, curTime ); } buildBoundingBox (); } Простейшим примером аниматора может служить класс, служащий для вращения объекта вокруг заданной оси с постоянной скоростью. Это как раз и делает следующий класс: Е1 class RotationAnimator : public Animator { private: Vector3D axis; float speed; float phase; public: RotationAnimator ( const char * theName, const Vector3D& theAxis, float theSpeed, float thePhase = 0 ) : Animator ( theName ) { axis = theAxis; speed = theSpeed; phase = thePhase; metaClass = &classlnstance; } -RotationAnimator (); virtual void animate ( VisualObject * object, float curTime ); static MetaClass classinstance; };
Ниже приводится реализация метода animate для этого класса. S void RotationAnimator :: animate ( VisualObject * object, float curTime ) { object -> animate (Transform3D :: getRotate ( axis, phase + speed * curTime )); } В результате этого появляется возможность легко задавать как сами объекты, так и способы их анимации прямо в sc-файле. Вместо явного задания всех параметров модели (координаты вершин, текстурные координаты для каждой из вершин и т. п.) удобнее будет прямо в sc-файле добавить поддержку нескольких стандартных типов моделей. Тогда в sc-файле можно будет просто задать имя файла с моделью и по нему будет построен объект, содержащий данную модель. На компакт-диске находятся загрузчики для следующих форматов: ase, md2 и md3. Шейдеры Еще одной полезной функциональностью, которую мы сейчас добавим, является поддержка шейдеров. Шейдер {shader) - это процедура, управляющая закрашиванием объекта. В данной главе мы рассмотрим простые многопроходные шейдеры, аналогичные применяющимся в игре Quake III Arena. Шейдеры, действующие на уровне отдельных пикселов (per-pixel shaders), будут рассматриваться во второй части работы. Работа такого шейдера заключается в последовательном наложении текстур на объект с заданным законом наложения. Каждую операцию наложения мы будем называть проходом (pass). Отдельный проход характеризуется текстурой, способом вычисления текстурных координат (environment mapping или аффинная модификация существующих координат), цветом и способом наложения текстуры (blending mode). Суммируя все это вместе, мы получаем следующий класс: 2' class ShaderPass :public Object { protected: Texture * texture; // blended texture Vector4D color; // used color int srcBlend; // blend modes
int dstBlend; bool envMapped; // whether we use // environment mapping Transform2D * textureTransf; // texture coordinates // transform public: ShaderPass ( const char * theName, Texture * theTexture, const Vector4D& theColor, bool env = false ); -ShaderPass () ; void setBlendingMode ( int s, int d ) { srcBlend = s; dstBlend = d; } void setTransform ( const Transform2D& tr ); virtual void drawPoly ( View& view, const Polygon3D * poly ) const; virtual void drawMesh ( View& view, const Camera& camera, const Vector4D& color, Fog * fog, const Mesh3D * mesh ) const; static MetaClass classinstance; } ; Ниже мы приводам реализацию метода drawMesh для рендеринга моделей. Е! void ShaderPass :: drawMesh ( View& view, const Camera& camera, const Vector4D& theColor, Fog * fog, const Mesh3D * mesh ) const { bool transp = srcBlend != View :: bmNone && dstBlend != View :: bmNone; glPushAttrib ( GL_COLOR_BUFFER_BIT | GL_ENABLE_BIT | GL_TRANSFORM_BIT ); if ( textureTransf != NULL ) // setup texture matrix { float m [16]; textureTransf -> buildHomogeneousMatrix ( m );
glMatrixMode ( GL_TEXTURE ); glPushMatrix (); glMultMatrixf ( m ) ; } if ( envMapped ) // setup spherical env. mapping { glTexGeni ( GL_S, GL_TEXTURE_GEN_MODE, GL_SPHERE_MAP ); glTexGeni ( GL_T, GL_TEXTURE_GEN_MODE, GL_SPHERE_MAP ); glEnable ( GL_TEXTURE_GEN_S ); glEnable ( GL_TEXTURE_GEN_T ) ; } if ( transp ) { glEnable ( GL_BLEND ); view.blendFunc ( srcBlend, dstBlend ); } I/ draw mesh mesh -> draw ( view, camera, color * theColor, fog, texture, transp ); // restore texture matrix if ( textureTransf != NULL ) glPopMatrix () ,- glPopAttrib (); } Как видно из приведенного кода, сначала устанавливается необходимый режим вывода, после чего вызывается метод Mesh3D :: draw. После этого восстанавливаются изменения в состоянии OpenGL. Класс Shader представляет собой коллекцию объектов типа ShaderPass, которые по очереди применяются к выводимому объекту. К1 class Shader : public Object { protected: Array passes; public: Shader ( const char * theName ) : Object ( theName ) { metaClass = kclasslnstance; }
void addPass ( ShaderPass * pass ) { passes.insert ( pass ),-} void removePass ( const Strings theName ) { passes.removeObjectWithName ( theName ); } ShaderPass * getPass ( const Strings theName ) { return (ShaderPass *) passes.getObjectWithName ( theName ); } void drawPoly ( Views view, const Polygon3D * poly ) const; void drawMesh ( Views view, const Cameras camera, const Vector4DS color. Fog * fog, const Mesh3D * mesh ) const; static MetaClass classinstance; }; Класс Shader осуществляет полное восстановление состояния OpenGL, используя вызовы функций glPushAttrib и glPopAttrib. void Shader :: drawMesh ( Views view, const Cameras camera, const Vector4DS color, Fog * fog, const Mesh3D * mesh ) const { // save OpenGL state glPushAttrib ( GL_COLOR_BUFFER_BIT | GL_ENABLE_BIT | GL_LIGHTING_BIT | GL_TEXTURE_BIT ); // perform passes for ( Array :: Iterator it = passes.getlterator (),-I it.end (); ++it ) { ShaderPass * pass = (ShaderPass *) it.value (); pass -> drawMesh ( view, camera, color, fog, mesh ); }
// restore OpenGL state glPopAttrib (); } Для поддержки шейдеров введем в класс MeshObject ссылку на используемый шейдер (лучше включить эту ссылку именно в класс MeshObject, а не в класс Mesh3D, поскольку разные экземпляры класса Mesh3D могут иметь разные шейдеры) и добавим в метод doDraw вызов соответствующего шейдера. Л. void MeshObject :: doDraw ( Views view, const Cameras camera, const Frustrums frustrum, Fog * fog ) { if ( shader == NULL ) mesh -> draw ( view, camera, color, fog ) ,-else shader -> drawMesh ( view, camera, color, fog, mesh ); } Примеры сцен, использующих шейдеры, можно найти на компакт-диске. Ниже приводится пример задания шейдера в sc-файле. Е) shader glass pass env < texture "../Textures/stars .bmp" mapping env src-blend one dst-blend one color (0.5, 0.5, 0.5, 0.5) }
НАПУТСТВИЕ На этом заканчивается первая часть предлагаемой вам книги. Если вы дочитали до этого места, то скорее всего вы уже можете построить простой игровой движок, обладающий целым рядом привлекательных возможностей. Однако ряд интересных тем, таких, как работа с поверхностями Безье и NURBS, рендеринг ландшафтных сцен, попикселовое освещение, уровни детализации моделей и очень многое другое, не вошли в эту книгу. Причиной этого является как ограниченный размер книги, так и желание, чтобы эта книга скорее увидела свет. Автор планирует подготовить вторую книгу, которая будет рассматривать работу с более сложными эффектами и потребует большего уровня подготовки читателя. Там будет рассматриваться ряд эффектов, которые лишь недавно появились и которыми далеко не все игры могут сейчас похвастаться. Планируется включить во вторую часть работу с плагинами (что позволит добавлять новую функциональность к уже существующей игре без изменения его исходного кода), сплайновые кривые и поверхности (Безье и NURBS), анимацию, попикселовое освещение с использованием возможностей современных графических ускорителей, работу ландшафтными сценами, уровни детализации (LOD) и ряд других возможностей. Веб-сайт поддержки книги расположен по адресу www.steps3d.narod.ru. Там вы можете найти полный исходный код для этой книги, постоянно обновляющийся список литературы и Интернет-ссылок по компьютерной графике. Планируется организовать форум по программированию графики и трехмерных игр на этом сайте. Если у вас появились какие-то замечания, предложения или просто вы хотите поделиться чем-то, что вы сделали и хотите показать другим (например, построенные вами сцены), то пишите по адресу steps3d@narod.ru.
Приложение ВЕКТОРНАЯ И МАТРИЧНАЯ АЛГЕБРА Через R мы будем обозначать множество всех вещественных чисел. Тогда двухмерное пространство можно представить как множество всех пар вида (х, у), где обе компоненты принимают вещественные значения. Обозначать такое пространство мы будем как R1 и, из соображений удобства, будем считать, что элементы пары расположены вертикально - один под другим: i€ R2,xe R,ye R. (П-1) Такой столбец мы будем называть двухмерным вектором. Для двухмерных векторов можно естественным образом ввести операцию сложения, определив ее покомпонеитио. Также можно ввести операцию умножения вектора на произвольное вещественное число: а.-и -а- а'х (П-3) По аналогии с пространством двухмерных векторов R2 можно ввести пространство «-мерных векторов R", состоящее из упорядоченных наборов из л вещественных чисел. , х, е R, i = 1,2,..., л. (П-4)
Для векторов из R" также можно ввести операции сложения и умножения на число аналогично соответствующим операциям для двухмерных векторов. Несложно убедиться в выполнении следующих простых свойств этих операций: u + v = v + u, (П-5) (н + v)+ w = и + (v + w). (П-6) Свойство (П-5), справедливое для двух любых векторов из R", называется коммутативностью, а свойство (П-6) называется ассоциативностью. Эти свойства непосредственно следуют из аналогичных свойств для вещественных чисел и определения операции сложения векторов в R". Также легко заметить, что существует вектор 0 (и притом единственный), такой,что и + 0 = 0 + и = и, и € R”, (П-7) и этот вектор состоит из одних нулей. Для любого вектора и можно определить вектор -и, такой, что и + (-и) = О, (П-8) -х, -и = 2 . (П-9) Умножение векторов на вещественные числа также обладает рядом свойств. (сф)и = а(0«). (П-10) (а + Р)н - аи + 0и. (П-11) а(н +v) = p« + av. (П-12) 1и = и. (П-13) Также можно определить скалярное произведение (dot product) для произвольных двух векторов и и v. Оно обычно обозначается как и v или (и, v) и определяется следующей формулой:
(«, у) = £и^- (П’14) /»1 Скалярное произведение векторов обладает следующими свойствами: (и, w) > 0, (и, и) = 0 <=> и = О, (П-15) (и + у, w) = (и, w) + (v, w), (П-16) (ош, у) = а(и, у), (П-17) (и, v) = (v, и). (П-18) Векторы и и v будем называть ортогональными или перпендикулярными, если их скалярное произведение равно нулю, т. е. (и, у) = 0. Можно ввести понятие длины или нормы вектора, определив его как корень из скалярного квадрата ||«|| = (П-19) При этом свойство (П-15) гарантирует корректность этого определения. Несложно заметить, что последние два определения в случае двух-и трехмерного пространства полностью согласуются с теми, которые вводятся в стандартном школьном курсе. Норма (длина) вектора обладает следующими свойствами: ||и|| = о <=> и = О, (П-20) Цос-мЦ = |ос|-||м||, (П-21) ||м + у||<М + ||у||, (П-22) |(М,у)|<||М||.||у||. (П-23) Можно ввести понятие угла а между векторами и и у, определив его следующим образом: (и, у) cos а = и и и и >и * 0’ у * 0. (П-24) МНИ Можно определить также проекцию вектора а на вектор Ь , определив ее как ^’-^Ь. Ml
В случае трехмерного пространства R3 можно также ввести понятие векторного произведения (cross product) двух векторов и и v, определив его следующим образом: [и, г]= их v = и.гх ~uxv. uv - и ,,vr \ х •' •' Векторное произведение обладает следующими свойствами: ихг = -гхи, (aM + Pv)xw = ai/xw+Pvxw, (их v)- w = (vxw)-k = (wXu)-V = = -(vxm)-w = -(uxw)-v = -(wxv)-h, hx(vXw) = (и, w)- v-(h, v)- w, (mxv) ± M,(«xv) ± V. (П-25) (П-26) (П-27) (П-28) (П-29) (П-30) Обратите внимание, что длина векторного произведения векторов равна удвоенной площади треугольника, построенного по этим векторам По аналогии с вектором, представляющим собой столбец вещественных чисел, можно ввести понятие матрицы как прямоугольной таблицы вещественных чисел: Я11 Я12 а1п а21 а22 а2п , ат\ ат2 атп t (П-31) Числа т и п называются размером матрицы, множество всех матриц размера т'/.п обозначается Rm'n. Иногда матрицу Л обозначают как («у), где скобки обозначают, что речь идет о всей совокупности элементов, а не об одном конкретном элементе. Если все элементы матрицы равны нулю, то такая матрица называется нулевой и обозначается как О . Матрица, для которой т = п , называется квадратной.
Квадратная матрица, где все элементы, не лежащие на главной диагонали, равны нулю, а на главной диагонали лежат единицы, называется единичной и обозначается как I (или Е). (1 0 ... ОА 1° 0 - U Для двух матриц А и В одинакового размера /нх/i можно ввести операцию сложения, определив сумму двух матриц как матрицу, состоящую из сумм соответствующих элементов обеих матриц. A+5 = (nJ + (^) = (n,?+^). (П-33) Легко, как и в случае векторов, убедиться в выполнении коммутативности и ассоциативности, а также в том, что добавление нулевой матрицы О не изменяет исходную матрицу. Также можно ввести операцию умножения матрицы на вещественное число, определив ее поэлементно. a-A = a-(nj/) = (a-«,j)- (П-34) Для этих операций справедливы следующие свойства: 0-А = О, 1-А = А, a(p-A) = (a-p)A, аО = О, (а + р)А = аА + рА, а(А + 5) = аА + аВ. (П 35) Кроме вышеприведенных операций можно ввести операцию транспонирования, где матрице А размера тхп становится в соответствие матрица А7 размера пхт, полученная путем "переворачивания" матрицы вдоль ее главной диагонали: А7 =(«,/=(«,). (П-36) Также для квадратной матрицы можно определить след матрицы, определив его как сумму диагональных элементов. trA = Ya.c (П-37)
Если имеются две матрицы А и В, размера тхп и nxl соответственно, то можно определить матрицу С размера mxl, являющуюся произведением этих матриц. Элементы этой матрицы определяются при помощи следующей формулы: s = 2я,а-£=1 Для произведения матриц справедливы такие свойства: (АВ)С = А(ВС), (А + В)С = АС+ВС, 1А = А1 = А. (П-38) (П-39) (П-40) (П-41) В общем случае АВ * ВА, даже если размеры матриц позволяют произвести оба умножения. Вектор можно рассматривать как матрицу из одного столбца, т. е. матрицу размера mxl; таким образом, можно умножить матрицу пхт на вектор размера т х 1. Для скалярного произведения выполняется следующее свойство: (Au, v) = (и, А1 г). (П-42) Для квадратной матрицы А можно ввести числовую характеристику, называемую определителем и обозначаемую как |а| или det А. Ниже приводятся формулы для вычисления определителей матриц 2x2 и 3x3. det «1 Я21 Я12 Я22? Я11Я22 Я12Я21 ’ (П-43) «1 det Я12 Я22 Я32 Я13 Я23 Я33. Я21 <Я31 ^12^23^31 ^”13^*21^*32 ^*13^^22^*31 ^*12^*21^*33 1 ^23й3’ * Для определителей справедливо следующее соотношение: det (АВ) = det А det В. Матрица называется вырожденной, если ее определитель равен нулю. (П-44)
Для невырожденной квадратной матрицы А можно построить квадратную матрицу А-1 такого же размера, называемую обратной матрицей к 77-Для обратной матрицы справедливо следующее соотношение: А А’1 = А~1А = 1. (П-45) Соотношение Ах = Ь. (П-46) называется системой линейных алгебраических уравнений порядка п . Необходимым и достаточным условием ее однозначной разрешимости для любой правой части b является отличие от нуля определителя матрицы этой системы (detA^O). В этом случае ее решение может быть записано в виде х = А-'Ь. (П-47) Необходимым и достаточным условием существования нетривиальных (т. е. отличных от нулевого вектора) решений у однородной системы Ах = 0 является как раз равенство нулю ее определителя det А = 0 (в противном случае нулевое решение является единственным). Определение. Если для ненулевого вектора х и числа X (вещественного или комплексного) выполнено следующее свойство: Ах=Гх, (П-48) то X называется собственным числом матрицы А, соответствующим собственному вектору х. Обратите внимание, что если х - собственный вектор матрицы А, то для любого вещественного а вектор ах также является собственным вектором для данной матрицы, причем он соответствует тому же самому собственному числу X. Соотношение (П-48) можно переписать в виде (А-Х/)х = 0. (П-49) Это соотношение является однородной системой линейных алгебраических уравнений, и для существования у нее нетривиальных решений необходимо и достаточно обращения в нуль ее определителя det(A-X/) = O. Данный определитель является многочленом степени п относительно ис
комого собственного числа Л и называется характеристическим многочленом матрицы А: p,(X) = det(A-X/). (П-50) Согласно основной теореме алгебры данное уравнение имеет ровно п корней с учетом кратности, но эти корни могут быть комплексными. Если матрица А симметричная (А = АТ), то все ее собственные значения вещественны и соответствующие собственные векторы попарно ортогональны, т. е. у такой матрицы существует ортогональный базис из собственных векторов.
ЛИТЕРАТУРА 1. Шикин Е. В., Боресков А. В. Компьютерная графика. Полигональные модели. М.: Диалог-МИФИ, 2000. 2. OpenGL. М. By. Официальное руководство программиста. СПб.: Диа-СофтЮП, 2002. 3. OpenGL. Официальный справочник. СПб.: ДиаСофтЮП, 2002. 4. Хилл Ф. OpenGL. Программирование компьютерной графики. СПб.: Питер, 2002. 5. Тихомиров Ю. Программирование трехмерной графики. СПб.: BHV-Санкт-Петербург, 1998. 6. Краснов М. OpenGL. Графика в проектах Delphi. СПб.: ВНV-Санкт-Петербург, 2000. 7. Эйнджел Э. Интерактивная компьютерная графика: Вводный курс на базе OpenGL. Вильямс, 2001. 8. Роджерс Д. Математические основы машинной графики. М.: Мир, 2001 9. Абраш М. Программирование графики. Таинства. Киев: ЕвроСиб, 1998. 10. Ласло М. Вычислительная геометрия и компьютерная графика на C++. М.: БИНОМ, 1997. 11. Порев В. Компьютерная графика. СПб.: BHV-Санкт-Петербург, 2002. 12. Гамма Э. и т. д. Приемы объектно-ориентированного проектирования. Паттерны проектирования. СПб.: Питер, 2001. 13. Ким Г. Д., Ильин В. А. Линейная алгебра и аналитическая геометрия. Изд-во Московского Университета, 1998. 14. Буч Г. Объектно-ориентированый анализ и проектирование с примерами приложений на C++. М.: БИНОМ, 1999. 15. Мейерс С. Эффективное использование C++: 50 рекомендаций по улучшению ваших программ и проектов. М.: ДМК, 2000. 16. Мейерс С. Наиболее эффективное использование C++: 35 новых рекомендаций по улучшению ваших программ и проектов. М.: ДМК, 2000. 17. Moller Т., Haines Е. Real-Time Rendering // А. К. Peters, 1999. 18. Eberly D. Н. 3D Game Endine Design 11 Morgan Kaufmann Publishers, 2001.
ИСТОЧНИКИ R ИИТЕРНК11 http://www.opengl.org http://www.flipcode.com http://www.wotsit.org http://www.gdmag.com http://www.gamedev.net http://www.gamedev.ru http://www.gametutorials.com http://www.gamasutra.com http://www.graphics3d.com http://www.hinjang.com/gfx.htm http://nomad.openglforums.com http://nate.scuzzy.net http://delphigl.cfxweb.net http://www.gldomain.com http://www.velocity.net http://nehe.gamedev.net http://www.cfxweb.net/pages/Articles http://www.enlight.ru/faq3d http://devmaster.net http://www.xdev.ru http://developer.nvidia.com http://www.ati.com/developer http://www.cs.wpi.edu/~matt/courses/cs563/talks/psys.html http://www.cs.unc.edu/~davemc/Particle http://www.icculus.org/homepages/phaethon/q3/formats/md3format.html http://www.deplhi3d.com
ОГЛАВЛЕНИЕ ПРЕДИСЛОВИЕ.................................................3 Глава 1. КООРДИНАТЫ И ИХ ПРЕОБРАЗОВАНИЯ.....................5 Основные преобразования в R2................................6 Поворот..................................................6 Растяжение-сжатие (масштабирование)......................6 Отражение................................................7 Перенос..................................................8 Аффинные преобразования в R3...............................16 Поворот.................................................16 Масштабирование.........................................17 Отражение...............................................18 Перенос.................................................18 Однородные координаты......................................18 Системы координат..........................................20 Задание ориентации.........................................21 Кватернионы................................................23 Проектирование.............................................30 Параллельное проектирование.............................31 Перспективное проектирование............................32 Глава 2. УДАЛЕНИЕ НЕВИДИМЫХ ПОВЕРХНОСТЕЙ.................34 Методы оптимизации.........................................38 Отсечение нелицевых граней..............................38 Ограничивающие тела (Bounding Volumes)..................40 Разбиение пространства(плоскости) (Spatial Subdivision).41 Иерархические структуры (Hierarchies)...................42 Метод трассировки лучей....................................43 Метод z-буфера.............................................43
Алгоритмы упорядочения.................................45 Метод сортировки по глубине. Алгоритм художника.....46 Метод двоичного разбиения пространства..............50 Метод порталов.........................................55 Множества потенциально видимых граней (PVS)............58 Глава 3. ПРОСТЕЙШИЕ ГЕОМЕТРИЧЕСКИЕ АЛГОРИТМЫ И СТРУКТУРЫ..................................64 Быстрая оценка длины вектора...........................64 Нахождение расстояния от точки до прямой...............65 Ограничивающие тела....................................66 Проверка пересечения луча с многоугольником.......... 89 Проверка пересечения двух многоугольников..............90 Иерархические структуры................................92 Область видимости......................................94 Глава 4. ОСНОВЫ БИБЛИОТЕКИ OpenGL......................98 Использование библиотеки glut.........................101 Рисование геометрических объектов.....................106 Рисование точек, линий и многоугольников..............110 Преобразование объектов в пространстве. Камера........113 Дисплейные списки.....................................122 Работа с z-буфером....................................123 Задание моделей закрашивания..........................124 Освещение.............................................125 Полупрозрачность. Использование а-канала..............130 Вывод битовых изображений.............................131 Ввод-вывод цветных изображений........................132 Наложение текстуры....................................133 Управление наложением текстуры........................147 Работа с буфером трафарета............................148 Сохранение параметров.................................150
Глава 5. ОБЪЕКТНАЯ МОДЕЛЬ. ОСНОВНЫЕ КЛАССЫ...........................................151 Глава 6. ОСНОВНЫЕ КЛАССЫ ДЛЯ РЕНДЕРЕРА. РАБОТА С РЕСУРСАМИ...............................180 Класс Polygon3D..................................180 Класс Texture....................................189 Класс ResourceManager............................194 Глава 7. ПИШЕМ ПОРТАЛЬНЫЙ РЕНДЕРЕР (часть I).....205 Схема "Модель - контроллер - вид"................205 Класс Timer......................................221 Класс Camera.....................................222 Метод порталов...................................226 Глава 8. ПИШЕМ ПОРТАЛЬНЫЙ РЕНДЕРЕР (часть И).....242 Работа с полупрозрачными гранями.................242 Консоль..........................................253 Обработка столкновений...........................257 Глава 9. ПИШЕМ ПОРТАЛЬНЫЙ РЕНДЕРЕР (часть III)....262 Глава 10. РАБОТА С КАРТАМИ ОСВЕЩЕННОСТИ..........275 Глава 11. ПИШЕМ РЕНДЕРЕР УРОВНЕЙ QUAKE II........290 Глава 12. ДОБАВЛЯЕМ ЭФФЕКТЫ......................309 Небо.............................................309 Объемный туман...................................314 Микрофактурные текстуры..........................323 Панели (billboard)...............................326 Системы частиц...................................338 Хало, блики на линзах............................346
Глава 13. ДОБАВЛЯЕМ МОДЕЛИ...............350 Модели...................................350 Шейдеры..................................364 НАПУТСТВИЕ...............................369 Приложение. ВЕКТОРНАЯ И МАТРИЧНАЯ АЛГЕБРА..370 ЛИТЕРАТУРА...............................378 ИСТОЧНИКИ В ИНТЕРНЕТЕ....................379
А. В. БОРЕСКОВ Графика трехмерной компьютерной игры на основе OPENGL
Книга посвящена основам программирования трехмерной графики в играх. В ней подробно рассматривается написание графического ядра для трехмерной игры, позволяющей в реальном времени перемещаться по заданной сцене. Достаточно подробно рассматриваются математические вопросы работы с координатными пространствами, преобразования и проектирование. Также приводится ряд геометрических алгоритмов для решения типовых задач и оптимизации. В качестве графической библиотеки, используемой для рендеринга сцены, используется широко распространенная библиотека OpenGL, для организации ввода информации от пользователя используется библиотека Directlnput. В книге подробно рассматривается организация работы с ресмурасами, включая загрузку как текстур в ряде форматов (bmp, jpg, png, gif, tga, wal, pcx), так и загрузку трехмерных моделей (ase, md2, md3). Большое внимание уделяется реализации ряда специальных эффектов, таких как зеркала и порталы с преобразованиями, объемный туман, системы частиц и т. п. Рассмотрение материала сопровождается примерами на языке C++ (для среды MS Visual C++ 6) и UML-диаграммами. Весь исходный код для книги доступен в интернете. Графика j трехмерной компьютерной игры на основе OPENGL ИЗДАТЕЛЬСТВО <^И41ОГ711И0И Тел.: 320-4377, 320-4355 Факс 320-3133 e-mail: dialoq@bitex.ru http://www.bitex.ru/~dialog ISBN 586404190-4