Ugrás a tartalomhoz

3D megjelenítési technikák

Dr. Fekete Róbert Tamás, Dr. Tamás Péter, Dr. Antal Ákos, Décsei-Paróczi Annamária (2014)

BME-MOGI

3D-s objektumok

3D-s objektumok

3D-s objektumok megadása

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).

4.37. ábra - Vertex és index puffer

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();
}

3D-s objektumok rajzolása

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.

4.38. ábra - 3D-s objektum

3D-s objektum


Objektum megvilágítása

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.

4.39. ábra - A megvilágított 3D-s objektum

A megvilágított 3D-s objektum


Az objektum forgatása

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.

4.40. ábra - A megvilágított 3D-s objektum

A megvilágított 3D-s objektum


Takart vonalak - hátsó lap eldobá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);

4.41. ábra - glDisable(GL_CULL_FACE)

glDisable(GL_CULL_FACE)


4.42. ábra - glEnable(GL_CULL_FACE)

glEnable(GL_CULL_FACE)