Урок 31. Визуализация моделей Milkshape 3D
В качестве источника этого проекта я взял PortaLib3D, библиотеку, которую я написал, чтобы тем, кто ей пользуется, было легко отображать модели, используя очень маленькую часть дополнительного кода. И хотя вы, конечно, можете доверить все библиотеке, вы должны понимать, что она делает, в этом вам и поможет данный урок.
Часть PortaLib3D включенная здесь содержит мое авторское право. Это не значит, что этот код не может быть использован вами - это значит, что если вы вырежете и вставите в свой проект часть кода, то вам придется сослаться на меня. Это все. Если вы сами разберете и переделаете код (то, что вы сделаете, если вы не используете библиотеку, и если вы не изучаете, что-то простое типа 'вырезать и вставить код'!), тогда вы освободитесь от обязательств. Давайте посмотрим, в коде нет ничего особенного! Ок, перейдем к кое-чему более интересному!
Основной OpenGL код.
Основной OpenGL код в файле Lesson31.cpp. Он, почти совпадает с уроком 6, с небольшими изменениями в секции загрузки текстур и рисования. Мы обсудим изменения позже.
Milkshape 3D
Модель, которую использованная в примере разработана в Milkshape 3D. Причина, по которой я использую ее в том, что этот пакет для моделирования чертовски хорош, и имеет свой собственный формат файлов, в котором легко разобраться и понять. В дальнейшем я планирую включить поддержку загрузки формата Anim8or, потому что он бесплатный и, конечно, загрузку 3DS.
Тем не менее, самое важное в формате файла, который здесь будет кратко обсуждаться, не просто загрузка модели. Вы должны создать свою собственную структуру, которая будет удобна для хранения данных, и потом читать файл в нее. Поэтому, в начале, обсудим структуры, необходимые для модели.
Структуры данных модели
Вот структуры данных модели представленные в классе Model в Model.h. Первое и самое важное, что нам надо - это вершины:
// Структура для вершины
struct Vertex
{
char m_boneID; // Для скелетной анимации
float m_location[3];
};
// Используемые вершины
int m_numVertices;
Vertex *m_pVertices;
Сейчас вы можете не обращать на переменную m_boneID внимания - рассмотрим ее в следующих уроках! Массив m_location представляет собой координаты точек (X, Y, Z). Две переменные хранят количество вершин и сами вершины в динамическом массиве, который создается загрузчиком.
Дальше нам надо сгруппировать вершины в треугольники:
// Структура треугольника
struct Triangle
{
float m_vertexNormals[3][3];
float m_s[3], m_t[3];
int m_vertexIndices[3];
};
// Используемые треугольники
int m_numTriangles;
Triangle *m_pTriangles;
Теперь 3 вершины составляют треугольник и хранятся в m_vertexIndices. Это смещения в массиве m_pVertices. При этом каждая вершина содержится в списке только один раз, что позволят сократить место в памяти (и в вычислениях, когда мы потом будем рассматривать анимацию). m_s и m_t - это координаты (s, t) в текстуре для каждой из 3-х вершин. Текстура используется только одна для данной сетки (которые будут описаны ниже). Наконец, у нас есть член m_vertexNormals, в котором хранится нормали к каждой из 3-х вершин. Каждая нормаль имеет 3 вещественные координаты, описывающие вектор.
Следующая структура, которую мы рассмотрим в модели, это сетка (mesh). Сетка - это группа треугольников, к которым применен одинаковый материал. Набор сеток составляет целую модель. Вот структура сетки:
// Сетка
struct Mesh
{
int m_materialIndex;
int m_numTriangles;
int *m_pTriangleIndices;
};
// Используемые сетки
int m_numMeshes;
Mesh *m_pMeshes;
На этот раз у нас есть m_pTriangleIndices, в котором хранится треугольники в сетке, в точности так же, как треугольники хранят индексы своих вершин. Этот массив будет выделен динамически, потому что количество треугольников в сетке в начала не известно, и определяется из m_num_Triangles. Наконец, m_materialIndex - это индекс материала (текстура и коэффициент освещения) используемый для сетки. я покажу структуру материала ниже:
// Свойства материала
struct Material
{
float m_ambient[4], m_diffuse[4], m_specular[4], m_emissive[4];
float m_shininess;
GLuint m_texture;
char *m_pTextureFilename;
};
// Используемые материалы
int m_numMaterials;
Material *m_pMaterials;
Здесь есть все стандартные коэффициенты освещения в таком же формате, как и в OpenGL: окружающий, рассеивающий, отражающий, испускающий и блестящий. У нас так же есть объект текстуры m_texture и имя файла (динамически располагаемое) текстуры, которые могут быть выгружены, если контекст OpenGL упадет.
Код - загрузка модели
Теперь займемся загрузкой модели. Вы увидите, что это чистая виртуальная функция, названная loadModelData, которая в качестве параметра имеет имя файла модели. Все что мы сделаем - это создадим производный класс MilkshapeModel, который использует эту функцию, которая заполняет защищенные структуры данных, упомянутые выше. Теперь посмотрим на функцию:
bool MilkshapeModel::loadModelData( const char *filename )
{
ifstream inputFile( filename, ios::in | ios::binary | ios::nocreate );
if ( inputFile.fail())
return false; // "Не можем открыть файл с моделью."
Для начала мы открыли файл. Это бинарный файл, поэтому используем ios::binary. Если файл не найден, функция возвратит false, что говорит об ошибке.
inputFile.seekg( 0, ios::end );
long fileSize = inputFile.tellg();
inputFile.seekg( 0, ios::beg );
Код дальше определяет размер файла в байтах.
byte *pBuffer = new byte[fileSize];
inputFile.read( pBuffer, fileSize );
inputFile.close();
Затем файл читается во временный буфер целиком.
const byte *pPtr = pBuffer;
MS3DHeader *pHeader = ( MS3DHeader* )pPtr;
pPtr += sizeof( MS3DHeader );
if ( strncmp( pHeader->m_ID, "MS3D000000", 10 ) != 0 )
return false; // "Не настоящий Milkshape3D файл."
if ( pHeader->m_version < 3 || pHeader->m_version > 4 )
return false; // "Не поддерживаемая
версия.
// Поддерживается
только Milkshape3D версии 1.3 и 1.4."
Теперь указатель pPtr будет указывать на текущую позицию. Сохраняем указатель на заголовок и устанавливаем pPtr на конец заголовка. Вы, наверное, заметили несколько структур MS3D, которые мы использовали. Они объявлены в начале MilkshapeModel.cpp и идут прямо из спецификации формата файла. Поля в заголовке проверяются, что бы убедиться, в правильности загружаемого файла.
int nVertices = *( word* )pPtr;
m_numVertices = nVertices;
m_pVertices = new Vertex[nVertices];
pPtr += sizeof( word );
int i;
for ( i = 0; i < nVertices; i++ )
{
MS3DVertex *pVertex = ( MS3DVertex* )pPtr;
m_pVertices[i].m_boneID = pVertex->m_boneID;
memcpy( m_pVertices[i].m_location, pVertex->m_vertex, sizeof( float )*3 );
pPtr += sizeof( MS3DVertex );
}
Текст выше читает каждую структуру вершины из файла. Начальная память для модели выделяется для вершин, а затем каждая вершина копируется, пока не будут обработаны все. В функции используются несколько вызовов memcpy которая просто копирует содержимое маленьких массивов. Член m_boneID пока по-прежнему игнорируется - он для скелетной анимации!
int nTriangles = *( word* )pPtr;
m_numTriangles = nTriangles;
m_pTriangles = new Triangle[nTriangles];
pPtr += sizeof( word );
for ( i = 0; i < nTriangles; i++ )
{
MS3DTriangle *pTriangle = ( MS3DTriangle* )pPtr;
int vertexIndices[3] = { pTriangle->m_vertexIndices[0],
pTriangle->m_vertexIndices[1],
pTriangle->m_vertexIndices[2] };
float t[3] = { 1.0f-pTriangle->m_t[0],
1.0f-pTriangle->m_t[1],
1.0f-pTriangle->m_t[2]
};
memcpy( m_pTriangles[i].m_vertexNormals,
pTriangle->m_vertexNormals,
sizeof( float
)*3*3 );
memcpy( m_pTriangles[i].m_s, pTriangle->m_s, sizeof( float )*3 );
memcpy( m_pTriangles[i].m_t, t, sizeof( float )*3 );
memcpy( m_pTriangles[i].m_vertexIndices, vertexIndices, sizeof( int )*3 );
pPtr += sizeof( MS3DTriangle );
}
Так же как и для вершин, эта часть функции сохраняет все треугольники модели. Пока что она включает просто копирование массивов из одной структуры в другую, и вы увидите разницу между массивом vertexIndeces и t-массивами. В файле номера вершин хранятся как массив переменных типа word, в модели это переменные типа int для согласованности и простоты (при этом противное приведение не нужно). Итак просто нужно привести 3 значения к типу int. Все значения t задаются как 1.0 - (оригинальное значение). Причина этого в том, что OpenGL использует левую нижнюю систему координат, тогда как Milkshape использует правую верхнюю систему координат (прим.: имеется в виду расположение точки центра системы координат и ориентация) для работы с текстурой. Это меняет направление оси y.
int nGroups = *( word* )pPtr;
m_numMeshes = nGroups;
m_pMeshes = new Mesh[nGroups];
pPtr += sizeof( word );
for ( i = 0; i < nGroups; i++ )
{
pPtr += sizeof( byte ); // Флаги
pPtr += 32; // Имя
word nTriangles = *( word* )pPtr;
pPtr += sizeof( word );
int *pTriangleIndices = new int[nTriangles];
for ( int j = 0; j < nTriangles; j++ )
{
pTriangleIndices[j] = *( word* )pPtr;
pPtr += sizeof( word );
}
char materialIndex = *( char* )pPtr;
pPtr += sizeof( char );
m_pMeshes[i].m_materialIndex = materialIndex;
m_pMeshes[i].m_numTriangles = nTriangles;
m_pMeshes[i].m_pTriangleIndices = pTriangleIndices;
}
Текст выше загружает данные структуры сетки (в Milkshape3D они называется группами "groups"). Так как число треугольников меняется от сетки к сетке, нет никакой стандартной структуры чтения. Поэтому берется поле за полем. Память для индексов треугольников выделяется динамически внутри сетки и читается по очереди.
int nMaterials = *( word* )pPtr;
m_numMaterials = nMaterials;
m_pMaterials = new Material[nMaterials];
pPtr += sizeof( word );
for ( i = 0; i < nMaterials; i++ )
{
MS3DMaterial *pMaterial = ( MS3DMaterial* )pPtr;
memcpy( m_pMaterials[i].m_ambient, pMaterial->m_ambient, sizeof( float )*4 );
memcpy( m_pMaterials[i].m_diffuse, pMaterial->m_diffuse, sizeof( float )*4 );
memcpy( m_pMaterials[i].m_specular,
pMaterial->m_specular,
sizeof( float
)*4 );
memcpy( m_pMaterials[i].m_emissive,
pMaterial->m_emissive,
sizeof( float
)*4 );
m_pMaterials[i].m_shininess = pMaterial->m_shininess;
m_pMaterials[i].m_pTextureFilename
= new char[strlen(
pMaterial->m_texture
)+1];
strcpy( m_pMaterials[i].m_pTextureFilename, pMaterial->m_texture );
pPtr += sizeof( MS3DMaterial );
}
reloadTextures();
Наконец, из буфера берется информация о материале. Это происходит так же, как и раньше, копированием каждого коэффициента освещения в новую структуру. Так же выделяется новая память для названия файла, содержащего текстуру, и оно копируется в эту память. Последний вызов reloadTextures используется собственно для загрузки текстур и привязки ее к объекту текстуры OpenGL. Эта функция из базового класса Model описывается ниже.
delete[] pBuffer;
return true;
}
Последний фрагмент освобождает память временного буфера, когда вся информация уже скопирована и работа процедуры завершена успешно.
Итак, в данный момент, защищенные члены класса Model заполнены информацией о модели. Заметьте, что это только код для MilkshapeModel, потому что все это относилось к специфике Milkshape3D. Теперь, перед тем как можно будет нарисовать модель, необходимо загрузить текстуры для всех материалов. Это мы сделаем в следующем куске кода:
void Model::reloadTextures()
{
for ( int i = 0; i < m_numMaterials; i++ )
if ( strlen( m_pMaterials[i].m_pTextureFilename ) > 0 )
m_pMaterials[i].m_texture = LoadGLTexture( m_pMaterials[i].m_pTextureFilename );
else
m_pMaterials[i].m_texture = 0;
}
Для каждого материала, текстура загружается, используя функцию из основных уроков NeHe (слегка измененных в отличие от предыдущих версий). Если имя файла с текстурой - пустая строка, то текстура не загружается, но взамен текстуре объекта присваивается 0, что означает, что нет никакой текстуры.
Код - рисование модели
Теперь можем начать код, рисующий модель! Теперь это совсем не сложно, когда у нас есть аккуратно расположенные структуры данных в памяти.
void Model::draw()
{
GLboolean texEnabled = glIsEnabled( GL_TEXTURE_2D );
Эта часть сохраняет состояние отображения текстур в OpenGL, поэтому функция не нарушит его. Заметьте, что она не сохраняет так же свойства материала.
Теперь цикл рисования каждой сетки по отдельности:
// Рисовать по группам
for ( int i = 0; i < m_numMeshes; i++ )
{
m_pMeshes[i] будет использован для ссылки на текущую сетку. Теперь, каждая сетка имеет свои свойства материала, поэтому мы устанавливаем соответствующее состояние OpenGL. Если, однако, materialIndex сетки равен -1, это значит, что материала для такой сетки нет, и она рисуется в стандартном виде OpenGL.
int materialIndex = m_pMeshes[i].m_materialIndex;
if ( materialIndex >= 0 )
{
glMaterialfv( GL_FRONT,
GL_AMBIENT,
m_pMaterials[materialIndex].m_ambient );
glMaterialfv( GL_FRONT,
GL_DIFFUSE,
m_pMaterials[materialIndex].m_diffuse );
glMaterialfv( GL_FRONT,
GL_SPECULAR,
m_pMaterials[materialIndex].m_specular );
glMaterialfv( GL_FRONT,
GL_EMISSION,
m_pMaterials[materialIndex].m_emissive );
glMaterialf( GL_FRONT, GL_SHININESS,
m_pMaterials[materialIndex].m_shininess );
if ( m_pMaterials[materialIndex].m_texture > 0 )
{
glBindTexture( GL_TEXTURE_2D,
m_pMaterials[materialIndex].m_texture );
glEnable( GL_TEXTURE_2D );
}
else
glDisable( GL_TEXTURE_2D );
}
else
{
glDisable( GL_TEXTURE_2D );
}
Свойства материала устанавливаются в соответствие со значением, сохраненным в модели. Заметим, что текстура используется и доступна, если ее индекс больше чем 0. Если поставить 0, то вы отказываетесь от текстуры, и текстура не используется. Так же текстура не используется, если для сетки вообще нет материала.
glBegin( GL_TRIANGLES );
{
for ( int j = 0; j < m_pMeshes[i].m_numTriangles; j++ )
{
int triangleIndex = m_pMeshes[i].m_pTriangleIndices[j];
const Triangle* pTri = &m_pTriangles[triangleIndex];
for ( int k = 0; k < 3; k++ )
{
int index = pTri->m_vertexIndices[k];
glNormal3fv( pTri->m_vertexNormals[k] );
glTexCoord2f( pTri->m_s[k], pTri->m_t[k] );
glVertex3fv( m_pVertices[index].m_location );
}
}
}
glEnd();
}
Секция выше производит рисование треугольников модели. Она проходит цикл для каждого из треугольников сетки и потом рисует каждую из 3-х вершин, используя нормали и координаты текстуры. Помните, что каждый треугольник в сетке, как и все вершины, пронумерованы в общих массивах модели (они используют 2 индексные переменные). pTri - указывает на текущий треугольник в сетке и используется для упрощения кода следующего за ним.
if ( texEnabled )
glEnable( GL_TEXTURE_2D );
else
glDisable( GL_TEXTURE_2D );
}
Заключительный фрагмент кода устанавливает режим отображения текстур в свое первоначальное состояние.
Другой важный кусок кода в классе Model - это конструктор и деструктор. Они сами все объясняют. Конструктор устанавливает все члены в 0-ое значение (или NULL для указателей), и деструктор удаляет динамические массивы из памяти для всех структур модели. Вы должны заметить, что если вызываете функцию loadModelData дважды, для объекта Model, то можете потерять часть памяти. Будьте осторожны!
Последняя тема, которую я буду здесь обсуждать, это изменение, в основном коде отображения используя новый класс модели, и прямо отсюда я планирую начать свой будущий урок о скелетной анимации.
Model *pModel = NULL; // Место для хранения данных модели
В начале кода lesson31.cpp была объявлена модель, но не инициализирована. Она создается в процедуре WinMain:
pModel = new MilkshapeModel();
if ( pModel->loadModelData( "data/model.ms3d" ) == false )
{
MessageBox( NULL, "Couldn't
load the model data/model.ms3d",
"Error",
MB_OK | MB_ICONERROR );
return 0; // Если модель не загружена, выходим
}
Модель создается здесь, и не в InitGL, потому что InitGL вызывается каждый раз, когда мы меняем графический режим (теряя контекст OpenGL). Но модель не должна перезагружаться, так как данные не меняются. Это не относиться к текстурам, которые присоединены к объектам текстур, когда мы загружаем объект. Поэтому добавлена следующая строка в InitGL:
pModel->reloadTextures();
Это место вызова LoadGLTextures, как и раньше. Если бы в сцене было несколько моделей, то эту функцию пришлось бы вызывать для каждой из них. Если все объекты стали вдруг белыми, то это значит, что с вашими текстурами что-то не так и они не правильно загрузились.
Наконец, вот новая функция DrawGLScene:
int DrawGLScene(GLvoid) // Здесь происходит все рисование
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // Очищаем экран и буфер глубины
glLoadIdentity(); // Сбрасываем вид
gluLookAt( 75, 75, 75, 0, 0, 0, 0, 1, 0 );
glRotatef(yrot,0.0f,1.0f,0.0f);
pModel->draw();
yrot+=1.0f;
return TRUE; // Продолжаем
}
Просто? Мы очистили цветовой буфер, установили единичную матрицу модели/вида, и потом устанавливается камера в режим gluLookAt. Если вы раньше не пользовались gluLookAt, то по существу она помещает камеру в позицию, описываемую 3-мя параметрами, перемещая центр сцены в позицию, описываемую 3-мя следующими параметрами, и последние 3 параметра описывают вектор направленный вверх. В данном случае мы смотрим из точки (75, 75, 75) в точку (0, 0, 0), так как модель расположена в центре системы координат, если вы не переместили ее до этого, и положительное направление оси Y смотрит вверх.
Функция должна быть вызвана вначале, но после сброса матрицы просмотра, таким образом, она работает.
Чтобы сделать сцену немного интереснее, постепенно вращаем ее вокруг оси y с помощью glRotatef.
Наконец, рисуем модель с помощью члена-функции рисования. Она рисуется в центре (она была создана в центре системы координат в Milkshape3D!), поэтому если вы хотите вращать ее, менять позицию или масштабировать, просто вызовите соответствующие функции GL перед рисованием. Вуаля! Чтобы проверить, сделайте свои собственные модели в Milkshape (или используйте функции импорта), и загрузите их, изменяя строки в функции WinMain. Или добавьте их в сцену и нарисуйте несколько объектов.
Что дальше?
В будущем уроке NeHe, я опишу, как расширить класс, чтобы объединить модель с анимационным скелетом. И если я вернусь к этому, то я напишу еще классы для загрузки, чтобы сделать программу более гибкой.
Шаг к скелетной анимации не такой уж и большой, как может показаться, хотя математика, привлекаемая для этого, достаточно хитра. Если вы что-то не понимаете в матрицах и векторах, пришло время прочитать что-нибудь по этой теме! Есть несколько источников в сети, которые вам в этом помогут.
Увидимся!
Информация о Brett Porter: родился в Австралии, был студентом University of Wollongong, недавно получил дипломы BCompSc и BMath. Он начал программировать на Бейсике в 12 лет на "клоне" Commandore 64, называемом VZ300, но скоро перешел на Паскаль, Intel ассемблер, C++ и ява. В течение последних нескольких лет его интересом стало 3-х мерное программирование, и его выбором в качестве API стал OpenGL. Для более подробной информации посетите его страницу http://rsn.gamedev.net/.
Продолжение этого урока Скелетной Анимацией можно найти на странице Brett'а.
© Brett Porter (brettporter@yahoo.com)
22 апреля 2002 (c) Валерий Провалов