Dr. Fekete Róbert Tamás, Dr. Tamás Péter, Dr. Antal Ákos, Décsei-Paróczi Annamária (2014)
BME-MOGI
A 3D-s objektumok megadása is csúcspont és attribútumaik segítségével történik. Ezeket kiszámolhatjuk kézzel is, mint ahogy eddig tettük, vagy egy 3D modellező programban megrajzolhatjuk az objektumokat, amelyeket betölt a programunk.
Az objektumok betöltéséhez ismerni kell az objektumot leíró fájl formátumát. Ilyen formátum pl. a szöveges Wavefront OBJ (.obj
), a bináris 3D Studio (.3ds
), vagy az XML alapú Collada (.dae
). A fájl betöltéséhez az Open Asset Import Library (Assimp) könyvtárat fogjuk használni. Az Assimp-et C++-ban írták, sok különböző típusú formátumot képes kezelni, amelyekhez egy egységes API-n keresztül lehet hozzáférni.
Gyakran előfordul, hogy egy modell különböző háromszögeinek ugyanaz a csúcspont is a része, ilyenkor felesleges újra eltárolni a vertexet. Ehelyett egy tömbben fogjuk tárolni az összes csúcspontot, és az attribútumokat, és egy külön tömbben tároljuk a háromszögeket alkotó vertexek indexeit (4.37. ábra - Vertex és index puffer).
3D grafikánál az árnyaláshoz a pozíciók mellett meg kell adni a csúcsokban lévő normál vektorokat is. A normál vektor egy egység hosszú vektor, amely merőleges a felületre. Definiáljunk két típust: egy 3-elemű vektort, amellyel a pozíciókat és normál vektorokat tudjuk leírni, illetve egy struktúrát amely egy oldal indexeit fogja tartalmazni.
struct vec3 { float x, y, z; vec3(float x, float y, float z) : x(x), y(y), z(z) { } } ; struct Face { unsigned int index[3]; } ;
Ezeket felhasználva készítjük el a példaprogramot! A 3D-s objektumokon is bemutatjuk az eddig megismert ábrázolásokat. Definiáljunk egy 3D-s objektumot leíró osztályt! Ez tartalmazni fogja a megfelelő puffereket, amelyeket tömb helyett a Standard Library-ben lévő std::vector
generikus típusú objektumban fogunk tárolni. Ennek előnye a hagyományos tömbkezeléssel szemben, hogy a memóriát magától fel fogja szabadítani az objektum megszűnésekor. Emellett definiáljunk két metódust, az egyik betölti az objektumot, a másik kirajzolja azt.
#include <vector> #include <assimp/Importer.hpp> #include <assimp/scene.h> #include <assimp/postprocess.h> class Mesh { std::vector<vec3> vertexes; std::vector<vec3> normals; std::vector<Face> indexes; public: void load(const char* filename); void render(); } ;
Írjuk meg a load()
metódust, amely betöltet az Assimp-el egy 3D-s fájlt, majd ebből a szükséges attribútumokat átalakítja a saját adatszerkezetünkre. Az Assimp használatához szükség van néhány fejállományra, illetve hozzá kell szerkeszteni az assimp.lib
fájlt a programhoz.
void Mesh::load(const char* filename) { // mesh betöltés Assimp::Importer importer; const aiScene* pScene = importer.ReadFile(filename, aiProcess_Triangulate); if (!pScene || pScene->mNumMeshes == 0) return; // mesh const aiMesh* pMesh = pScene->mMeshes[0]; …
Az Assimp::Importer
osztályon keresztül lehet betölteni egy fájlt, amelyre vissza ad egy const aiScene
pointert. Ez az adatstruktúra tartalmazza többek között az objektumokat (mesh). Emellett tartalmazhatja a színteret is, azaz, hogy az egyes mesh-ek milyen hierarchikus viszonyban vannak egymással, és hol helyezkednek el. A ReadFile()
metódusnak megadtuk az aiProcess_Triangulate
flaget, amivel elérjük, hogy az Assimp
a sok pontból álló poligonokat alakítsa át háromszögekké.
A példában egy .obj
fájlból töltünk be egy teáskannát, és feltételezzük, hogy csak egy mesh lesz a fájlban. Ezután a saját adatszerkezetünkre alakítjuk át az aiMesh
-ben lévő információt.
// memóriafoglalás vertexes.reserve(pMesh->mNumVertices); normals.reserve(pMesh->mNumVertices); indexes.reserve(pMesh->mNumFaces); // vertexek másolása for (unsigned int i = 0; i < pMesh->mNumVertices; ++i) vertexes.push_back(vec3( pMesh->mVertices[i].x, pMesh->mVertices[i].y, pMesh->mVertices[i].z)); // normálisok másolása for (unsigned int i = 0; i < pMesh->mNumVertices; ++i) normals.push_back(vec3( pMesh->mNormals[i].x, pMesh->mNormals[i].y, pMesh->mNormals[i].z)); // indexek másolása for (unsigned int i = 0; i < pMesh->mNumFaces; ++i) { Face face; face.index[0] = pMesh->mFaces[i].mIndices[0]; face.index[1] = pMesh->mFaces[i].mIndices[1]; face.index[2] = pMesh->mFaces[i].mIndices[2]; indexes.push_back(face); } }
Az std::vector
magától nagyobb memóriát foglal, ha betelik az előre lefoglalt terület, azonban ezt elkerülhetjük, mert tudjuk, hogy pontosan hány elem fog belekerülni. Ezután az aiMesh
struktúrából átmásoljuk a számunkra érdekes részeket.
A rendereléskor végiglépkedünk az összes háromszögen, és az indexek alapján kirajzoljuk a megfelelő csúcspontokat.
void Mesh::render() { glBegin(GL_TRIANGLES); for (std::vector<Face>::const_iterator it = indexes.begin(); it != indexes.end(); ++it) { for (int j = 0; j < 3; ++j) { glNormal3f( normals[it->index[j]].x, normals[it->index[j]].y, normals[it->index[j]].z); glVertex3f( vertexes[it->index[j]].x, vertexes[it->index[j]].y, vertexes[it->index[j]].z); } } glEnd(); }
A main()
függvény a szokásos módon néz ki, létrehoz egy ablakot mélység pufferrel, és dupla puffereléssel.
int main(int argc, char* argv[]) { glutInit(&argc, argv); glutInitWindowSize(640, 480); glutInitWindowPosition(0, 0); glutInitDisplayMode(GLUT_RGBA | GLUT_DEPTH | GLUT_DOUBLE); glutCreateWindow(”Hello 3D”); glutDisplayFunc(onDisplay); glutReshapeFunc(onResize); glutIdleFunc(onIdle); onInit(); glutMainLoop(); return 0; }
A nemrég megírt Object
osztályból hozzunk létre egy példányt globális változóként, majd az onInit()-
ben töltsük be a modellt. Később forgatni fogjuk a modellt, amihez deklaráljunk még 2 globális változót!
Mesh teapot; float angle = 0.0f; // forgatás mértéke fokokban int lastTime = 0; void onInit() { glClearColor(0.1f, 0.2f, 0.3f, 0.0f); glShadeModel(GL_SMOOTH); glEnable(GL_DEPTH_TEST); lastTime = glutGet(GLUT_ELAPSED_TIME); teapot.load(”teapot.obj”); }
Az onResize()
függvényben kezeljük az ablak átméretezését, és itt beállítunk – az eddigiekkel ellentétben – egy perspektivikus vetítést a már ismert gluPerspective()
függvénnyel.
void onResize(int width, int height) { glViewport(0, 0, width, height); if (height == 0) height = 1; double aspect = static_cast<double>(width) / static_cast<double>(height); glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(60.0, aspect, 0.1, 100.0); glMatrixMode(GL_MODELVIEW); }
Az onIdle()
függvény a szokásos módon méri az utolsó hívás óta eltelt időt, és frissíti a forgatáshoz használt angle nevű változót.
void onIdle() { // idő mérés int now = glutGet(GLUT_ELAPSED_TIME); float dt = (now - lastTime) / 1000.0f; lastTime = now; angle += 36.0 * dt; glutPostRedisplay(); }
Végül már csak az onDisplay()
-t kell megírni.
void onDisplay() { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glLoadIdentity(); gluLookAt( -0.5, 2.0, 3.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0 ); teapot.render(); glutSwapBuffers(); }
Első lépésként töröljük a szín-, és mélységi puffer tartalmát. Ezután egységmátrixot állítunk a modelview mátrixba. Az onResize()
végén a projection mátrix után újra a modelview mátrixot választottuk ki, így ezt módosítjuk.
Ezután beállítunk egy nézeti transzformációt a gluLookAt()
függvénnyel, amelynek meg kell adni a kamera pozícióját (első 3 paraméter), azt, hogy melyik pontba néz (második 3 paraméter), illetve a felfele irányt (harmadik 3 paraméter). A felfele irány megadásával lehet a kamerát az optikai tengelye mentén forgatni. Majd kirajzoljuk az objektumot.
Habár megjelent a betöltött modell, de nem pont ezt vártuk. Igazából jól működik az OpenGL, mert ő csak a megadott háromszögeket rajzolja ki, és mivel nem adtunk meg más színt, ezért minden az alapértelmezett fehér színnel jelenik meg.
Az árnyalás a fényforrás iránya, és a normál vektorok iránya alapján történik. A normál vektorokról azt gondolja az OpenGL, hogy egység hosszúak, azonban a különböző transzformációk hatására ez változhat. Ezért a GL_NORMALIZE
kapcsolóval megkérhetjük az OpenGL-t, hogy az árnyalás előtt normalizálja a normál vektorokat. Ez persze nincs ingyen, de a mai GPU-kon ez gyorsan végbemegy, ezért érdemes bekapcsolni.
void onInit() { glClearColor(0.1f, 0.2f, 0.3f, 0.0f); glShadeModel(GL_SMOOTH); glEnable(GL_DEPTH_TEST); glEnable(GL_LIGHTING); glEnable(GL_LIGHT0); glEnable(GL_NORMALIZE); lastTime = glutGet(GLUT_ELAPSED_TIME); teapot.load(”teapot.obj”); }
Így már azt a képet kapjuk, amelyet az előbb vártunk.
Az onIdle()
-ben folyamatosan számoljuk a forgatási szöget, de eddig még nem használtuk sehol. Az onDisplay()
-ben a gluLookAt()
hívás után a glRotatef()
függvénnyel beállíthatunk egy forgatás transzformációt az objektumra. Az első paramétere a forgatás mértéke fokokban, a többi 3 pedig a tengely, ami körül a forgatás történik, ami most az Y-tengely lesz.
void onDisplay() { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glLoadIdentity(); gluLookAt( -0.5, 2.0, 3.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0 ); glRotatef(angle, 0.0f, 1.0f, 0.0f); teapot.render(); glutSwapBuffers(); }
Az OpenGL-ben megadott transzformációkat “alulról felfelé” kell megadni a mátrixszorzás sorrendje miatt. Azaz az objektumot először forgatni fogjuk az Y tengely körül, ezután kerül be a kamera koordináta-rendszerbe. Ezt a modelview mátrix tartalmazza. Ezután a kamera koordináta-rendszerből a projection mátrix átalakítja a vertexeket normalizált eszköz koordinátákba, és megtörténik a raszterizálás.
A teáskanna nagyjából tömör, így nem látjuk a hátul lévő háromszögeket. Azonban ezeket is kirajzoljuk, a depth puffer algoritmus azonban megoldja, hogy ezek ne látszódjanak. A képernyőre vetítve egy háromszöget, a vertexek megadási sorrendje meghatároz egy körül járási irányt, amely lehet az óra mutató járásával megegyező, vagy ellentétes. A háromszögeket az óra mutató járásával ellentétes irányba szokás megadni. Így az elöl lévő háromszögek ebben az irányban lesznek, a hátul lévők megfordulnak, és az óramutató járásával megegyező irányban állnak.
A glEnable(GL_CULL_FACE)
hívással engedélyezhetjük, hogy a hátsó lapokat, a körül járási irány alapján, a raszterizálás előtt eldobja a rendszer (culling). A glFrontFace()
függvénnyel lehet megadni, hogy az óramutató járásával ellentétes (GL_CCW
), vagy megegyező irányú (GL_CW
) háromszögeket kezelje elöl lévőként az OpenGL. Az onInit()
-be szúrjuk be a következő sorokat:
glEnable(GL_CULL_FACE); glFrontFace(GL_CCW);
Így ugyanazt látjuk, mint eddig. Ha megfordítjuk a körül járási irányt (glFrontFace(GL_CW)
), akkor az objektum belsejét fogjuk látni.
A hátsó lap eldobás hatását másképp is meg lehet nézni. A glPolygonMode()
hívással be lehet állítani, hogy az elöl és hátul lévő háromszögek hogyan legyenek raszterizálva (pontként - GL_POINT
, élként - GL_LINE
, kitöltéssel - GL_FILL
). Az onInit()
-be szúrjuk be valahova a következő sort, és próbáljuk ki a kódot glEnable(GL_CULL_FACE)
hívással, és nélküle is!
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);