Εισαγωγή στον προγραμματισμό 3D γραφικών με το OpenGL

Γιάννης Τσιομπίκας

Στο προηγούμενο άρθρο είδαμε εν συντομία κάποια βασικά πράγματα γύρο απο τον προγραμματισμό γραφικών, και συγκεκριμένα για το πως χρησιμοποιούμε το OpenGL για να ζωγραφίσουμε πολυγονικά αντικείμενα στην οθόνη. Καλύψαμε το rendering pipeline με τους μετασχηματισμούς απο τους οποίους περνάνε τα πολύγωνα ενος αντικειμένου πρίν καταλήξουν να προβληθούν στο επίπεδο τις εικόνας, και εξηγήσαμε και το πώς το OpenGL μας βοηθάει να λύσουμε το visible surface determination πρόβλημα, χρησιμοποιώντας z-buffering.

Σε αυτό το άρθρο, θα συνεχίσουμε απο το σημίο που σταματίσαμε, και θα εξηγήσουμε πως μπορούμε να χρησιμοποιήσουμε το matrix stack του OpenGL για να διαχορίσουμε εύκολα το world απο το view space, όστε να δώσουμε σε κάθε αντικείμενο το δικό του world transformation. Τέλος θα πούμε λίγα λόγια για τον φωτισμό, και θα φωτίσουμε τα αντικείμενα μας με το phong reflectance model, προσδίδωντας περισσότερο ρεαλισμό στην σκηνή.

Για εξοικονώμιση χώρου, δεν θα παρατεθεί ολόκληρος ο κώδικας για τα καινούρια παραδείγματα, αλλα θα αναφερθούν μόνο οι αλλαγές. Άν δεν έχετε το προηγούμενο άρθρο μπροστά σας, ο πλήρης κώδικας των παραδειγμάτων, καθώς και το κείμενο του προηγουμένου άρθρου, βρίσκωνται στο dvd του περιοδικού.

Στοίβα μετασχηματισμών

Το OpenGL μας παρέχει μια πολύ βολική στοίβα πινάκων μετασχιματισμού για κάθε στάδιο του rendering pipeline. Ουσιαστικά, όταν θέτουμε η πολλαπλασιάζουμε έναν πίνακα μετασχηματισμού του OpenGL με συναρτήσεις όπως glLoadIdentity, glLoadMatrix, glMultMatrix, glTranslate, glRotate, κλπ. επηρεάζουμε το κορυφαίο στοιχείο της στοίβας.

Οι συναρτήσεις που θα χρησιμοποιήσουμε για την διαχείριση του matrix stack είναι οι εξείς:

Η στοίβα μετασχηματισμών του OpenGL είναι εξαιρετικά χρήσιμη για να χειριζόμαστε ιεραρχικούς μετασχηματισμους, κάτι στο οποίο δεν θα αναφερθούμε σε αυτό το άρθρο. Παρόλα αυτα, μπορεί να χρησιμοποιηθεί και απλά για να "σώσουμε" την κατάσταση του ενεργού πίνακα μετασχιματισμού σε οποιοδήποτε σημείο, όστε να την επαναφέρουμε αργότερα. Αυτό είναι υπερβολικά χρήσιμο κυρίως σε συνδιασμό με το modelview matrix, στο οποίο πρέπει να συμπεριλάβουμε τον υσχύων μετασχηματισμό θέασης (view matrix), το οποίο δεν αλλάζει κατα την διάρκεια του rendering ενός καρέ και υσχύει για όλα τα αντικείμενα της σκηνής, αλλά και το world matrix του κάθε αντικειμένου.

Σύμφωνα με τα άνωθε, μπορούμε να ζωγραφίζουμε όλα τα αντικείμενα μιας σκηνής με τον ακόλουθο ψευδοκώδικα:

set view matrix
for all objects {
    glPushMatrix()
    multiply object's world matrix
    render object
    glPopMatrix()
}

Βλέπουμε οτι στην αρχή του κάθε iteration, σώζουμε το κορυφαίο matrix, το οποίο τυχαίνει να είναι το view matrix που θέσαμε έξω απο το loop, κατόπιν πολλαπλασιάζουμε το world matrix του αντικειμένου, οπότε πρίν ζωγραφιστεί το αντικείμενο έχουμε θέσει το συνολικό world/view matrix. Τέλος αφού ζωγραφίσουμε τα πολύγωνα του αντικειμένου, επαναφέρουμε το σκέτο view matrix στην κορυφή κάνωντας ένα pop. Έτσι, στο επόμενο iteration, πάλι η κορυφή του matrix stack περιέχει μόνο το view matrix, έτοιμο να γίνει συνδιαστεί με το world matrix του επόμενου αντικειμένου.

Ας δούμε ένα παράδειγμα οπου ζωγραφίζουμε 5 κύβους (όπως ζωγραφίσαμε τον ένα κύβο στο προηγούμενο άρθρο), σε σχηματισμό σταυρού, με ένα μεγάλο κύβο στη μέση και 4 μικρότερους γύρο του.

Κατ'αρχάς, ασ φτίαξουμε μια συνάρτηση deaw_cube, για να κρύψουμε μέσα σε αυτή όλα τα glVertex/glColor calls που χρειάζονται για να ζωγραφιστεί ο κύβος.

void draw_cube(void)
{
	glBegin(GL_QUADS);
	/* .. opos sto proigoumeno arthro .. */
	glEnd();
}

Μετά ας φτιάξουμε ένα structure με τις πληροφορίες που χρειαζόμαστε για τους κύβους μας, θέση και μέγεθος, και ένα array απο τέτοια structures με 5 στοιχεία, ένα για κάθε κύβο.

struct object {
	float x, y, z; /* position */
	float size;    /* cube size */
} cubes[] = {
	{0, 0, 0, 1},
	{-4, 0, 0, 0.5},
	{4, 0, 0, 0.5},
	{0, 0, -4, 0.5},
	{0, 0, 4, 0.5}
};

int num_cubes = sizeof cubes / sizeof cubes[0];

Τέλος ας αλλάξουμε την display, και ας την κάνουμε ως εξής:

void display(void)
{
	int i;
	glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

	glMatrixMode(GL_MODELVIEW);
	/* set view matrix by calling gluLookAt */
	glLoadIdentity();
	gluLookAt(-3, 6, 8, 0, 0, 0, 0, 1, 0);

	for(i=0; i<num_cubes; i++) {
		/* save current matrix */
		glPushMatrix();
		/* multiply object's world matrix by calling
			glTranslate/glRotate helper functions */
		glTranslatef(cubes[i].x, cubes[i].y, cubes[i].z);
		glScalef(cubes[i].size, cubes[i].size, cubes[i].size);

		draw_cube(); /* draw the cube */
		/* restore the view matrix by popping */
		glPopMatrix();
	}

	glutSwapBuffers();
}

Όπως βλέπουμε, πρώτα θέτουμε το view matrix. Η gluLookAt υπολογίζει ένα view matrix και το πολλαπλασιάζει στο υπάρχον, δεχόμενη ως παραμέτρους τρία διανύσματα, την θέση της εικονικής κάμερας (-3, 6, 8), το σημείο στο οποίο κοιτάει (0, 0, 0), και ένα διάνυσμα που δείχνει πού είναι το "πάνω" (0, 1, 0). Κατόπιν κάνουμε ενα loop για όλους τους κύβους στο array, στο οποίο μέσα σώζουμε το παρών matrix, πολλαπλασιάζουμε απο πάνω το world matrix του αντικειμένου χρησιμοιώντας τις βοηθιτικές συναρτίσεις glTranslatef και glRotatef, καλούμε την draw_cube για να ζωγραφιστούν τα πολύγωνα του κύβου, και τέλος καλούμε την glPopMatrix, για να επαναφέρουμε το αρχικό matrix που περιέχει μόνο το view κομάτι. Το αποτέλεσμα φαίνεται στην εικόνα 1.

multiple cubes in world coords

Εικόνα 1: Πολλαπλοί κύβοι με δικό τους world transformation.

Σκίαση και Φωτισμός

Μεγάλο μέρος της φωτορεαλιστικής ψευδαίσθησης στα γραφικά παίζει ο φωτισμός. Όταν μιλάμε για φωτισμό, ενοούμε αλγορίθμους που μας επιτρέπουν να υπολογίσουμε τι χρώμα να δώσουμε σε κάθε επιφάνεια (η ακόμα και σε κάθε pixel) των αντικειμένων που ζωγραφίζουμε, ώστε να φαίνονται σαν να υπάρχει μια, η περισσότερες, φωτεινές πηγές τρυγίρω που τα φωτίζουν. Για να επιτύχουμε αυτό το αποτέλεσμα, χρειάζεται να εξετάσουμε δύο θέματα: αλγορίθμους σκίασης (shading), και μοντέλα φωτισμού (illumination models ή reflectance models).

Αλγόριθμοι Σκίασης

Όταν ένας ζωγράφος θέλει να προσδώσει ρεαλισμό στα αντικείμενα που ζωγραφίζει, χρησιμοποιεί τεχνικές σκίασης. Δηλαδή για παράδειγμα ενα κόκκινο αντικείμενο δε θα ζωγραφιστεί όλο με το ίδιο κόκκινο χρώμα, αλλα με διαφορετικές αποχρώσεις του κόκκινου. Η πλευρά του αντικειμένου που κοιτάει προς το φώς πρέπει να φαίνεται φωτεινή μιας και ανακλά περισσότερο φώς προς τον παρατηριτή, οπότε χρωματίζεται ανοιχτό κόκκινο, ενώ όσο πηγαίνουμε προς τα πίσο, σιγά-σιγά σκουραίνει.

Για να επιτύχουμε το ίδιο αποτέλεσμα στο προγραμμά μας, πρέπει κατ'αρχάς να μπορούμε να περιγράψουμε μαθηματικά το "προς τα πού κοιτάει" το κάθε σημείο του αντικειμένου. Για αυτό τον σκοπό ορίζουμε διανύσματα, κάθετα στην επιφάνεια του αντικειμένου, τα οποία ονομάζονται "normal vectors", ή πιο απλά "normals".

Σε ποιά ακριβώς σημεία του αντικειμένου ορίζουμε αυτά τα normals, έχει να κάνει με τον αλγόριθμο shading που θα χρησιμοποιήσουμε. Υπάρχουν τρείς επιλογές όταν μιλάμε για πολυγωνικά αντικείμενα όπως αυτα που χειριζεται το OpenGL (σχήμα 1):

Shading models and normals

Σχήμα 1: Μοντέλα σκίασης και normals

Απο τις παραπάνω μεθόδους, το OpenGL υποστιρίζει flat και gouraud shading. Μπορούμε να χρησιμοποιήσουμε phong shading μόνο εαν γράψουμε δικούς μας shaders, ή αν έχουμε πρόσβαση σε κάποια graphics workstations της Silicon Graphics, τα οποία υποστηρίζουν phong shading ώς extension του OpenGL.

Το OpenGL συμπεριλαμβανει σε κάθε vertex και ένα normal vector, οπότε για να δώσουμε τα normals μας στο OpenGL αρκεί πρίν απο κάθε glVertex call, να καλέσουμε την glNormal για να ορίσουμε το αντίστοιχο normal vector του vertex που ακολουθεί. Όπως και με τα glColor calls, αν καλέσουμε μόνο ένα glNormal για μια σειρά απο glVertex calls, όλα αυτά τα vertices θα χρησιμοποιήσουν το ίδιο normal. Συγκεκριμένα στον κύβο μας, που χρειαζόμαστε ένα normal για κάθε πλευρά του κύβου, αρκεί να καλέσουμε μια φώρα την glNormal για κάθε τετράπλευρο που ζωγραφίζουμε. Οπότε ας αλλάξουμε τον κώδικα της draw_cube, και ας τοποθετίσουμε τις παρακάτω κλήσεις στην glNormal:

void draw_cube(void)
{
	glBegin(GL_QUADS);
	/* far face (-Z) */
	glNormal3f(0, 0, -1);
	...
	/* top face (+Y) */
	glNormal3f(0, 1, 0);
	...
	/* bottom face (-Y) */
	glNormal3f(0, -1, 0);
	...
	/* right face (+X) */
	glNormal3f(1, 0, 0);
	...
	/* left face (-X) */
	glNormal3f(-1, 0, 0);
	...
	/* near face (+Z) */
	glNormal3f(0, 0, 1);
	...
	glEnd();
}
Για τον πλήρη κώδικα δείτε το gl2.c στο dvd του περιοδικού.

Μοντέλα φωτισμού

Τώρα που ξέρουμε για το shading και για το πώς ορίζουμε normals σε ένα OpenGL πρόγραμμα, μπορούμε να προχωρίσουμε στην θεωρία πίσο απο τον φωτισμό. Το ζητούμενο είναι να υπολογίσουμε σε κάποιο σημείο, χρησιμοποιώντας το αντίστοιχο normal vector, την "ένταση" του φωτός που ανακλάται απο αυτό το σημείο προς τον θεατή.

Το πιο απλό μοντέλο που μπορούμε να χρησιμοποιήσουμε, είναι το γνωστό ώς Lambert's cosine law (νόμος συνημιτόνου), και περιγράφει matte επιφάνειες (γνωστές και ως lambertian surfaces) που διαχέουν το προσπίπτων φώς ισόποσα προς όλες τις κατευθύσεις του ιμισφαιρίου πάνω απο την επιφάνεια, αντί να το ανακλούν προς συγκεκριμένη κατεύθυνση όπως οι γυαλιστερές επιφάνειες. Το cosine law λέει ότι η ένταση του ανακλώμενου φωτός απο την επιφάνεια είναι ίση με το συνημίτονο της γωνίας ανάμεσα στο normal vector και ένα διάνυσμα που δίχνει προς την κατεύθυνση του φωτός (διανύσματα N και L στο σχήμα 2). Έτσι όταν το φώς είναι ακριβώς κάθετο στην επιφάνεια, τα δύο διανύσματα συμπίπτουν, η γωνία τους ειναι 0, και το συνημίτονο είναι 1, οπότε η επιφάνεια φαίνεται πλήρως φωτισμένη. Όταν το φώς "ξύνει" την επιφάνεια με ακτίνες παράλληλες προς αυτήν, τα δύο διανύσματα είναι κάθετα (γωνία 90 μοιρών) και το συνημίτονο είναι 0, οπότε η επιφάνεια δεν φωτίζεται.

shading calculation

Σχήμα 2: Υπολογισμός φωτισμού

Το συνημίτονο της γωνίας μεταξύ δύο διανυσμάτων, υπολογίζεται πολύ εύκολα χρησιμοποιώντας το εσωτερικό γινόμενο (dot product). Συγκεκριμένα, το εσωτερικό γινόμενο μεταξύ δύο διανυσμάτων A και B, ισούται με |A||B|cosθ. Έτσι, αν σιγουρευτούμε οτι το A και το B είναι μοναδιαία διανύσματα, τότε το εσωτερικό τους γινόμενο μας δίνει απευθίας το συνημίτονο της γωνίας τους.

Η ένταση που υπολογίσαμε (τιμές απο 0 έως 1), πολλαπλασιάζεται με το χρώμα του αντικειμένου σε κάθε σημείο, ώστε να αποκτίσει φωτεινές και σκοτεινές αποχρώσεις αναλόγως με το πόσο φωτίζεται το κάθε σημείο.

Για να πούμε στο OpenGL να υπολογίσει φωτισμό για κάθε vertex των πολυγώνων που ζωγραφίζουμε, πρέπει να ενεργοποιήσουμε τον φωτισμό, να ενεργοποιήσουμε τουλάχιστον μια φωτινή πηγή, και να ορίσουμε σε ποιό σημείο του χώρου αυτή βρίσκεται.

Προσθέστε στην main, μετά το glEnable(GL_DEPTH_TEST), και τα εξής:

glEnable(GL_LIGHTING);  /* energopoioume ton ypologismo fotismou */
glEnable(GL_LIGHT0);    /* energopoioume to proto fos */
glEnable(GL_NORMALIZE); /* leme sto OpenGL na kanei ola ta normals monadiaia */

Ο ορισμός της θέσης του φωτός γίνεται με τις glLight συναρτίσεις. Για να τοποθετηθεί το φώς σε world space πρέπει να καλέσουμε την glLight μετά απο το σημείο που θέτουμε το view matrix, αλλα πρίν πολλαπλασιάσουμε οποιοδήποτε world matrix κάποιου αντικειμένου. Η glLightfv που θα χρησιμοποιήσουμε, παίρνει σαν παράμετρο ένα array απο 4 floats με το διάνυσμα θέσης της φωτινής πηγής. Το τελευταίο στοιχείο του array πρέπει να είναι πάντα 1 όταν ορίζουμε σημιακές πηγές φωτός (προς το παρών δέ θα αναφερθούμε σε άλλους τύπους φωτεινών πηγών).

Οπότε προσθέστε κατ'αρχάς το εξής array στην κορυφή της display συνάρτησης:

float lpos[] = {4, 5, 10, 1};
Και την παρακάτω κλήση πρίν απο το loop που ζωγραφίζει τους κύβους, αλλα μετά απο τις κλήσεις συναρτήσεων που θέτουν το view matrix:
glLightfv(GL_LIGHT0, GL_POSITION, lpos);

Εάν τώρα τρέξετε το παράδειγμα, θα δείτε τους 5 κύβους μας, αυτή τη φορά φωτισμένους, αλλα γκρίζους (εικόνα 2). Το OpenGL δέν πολλαπλασιάζει τα χρώματα που δίνουμε με την glColor, κατα τον υπολογισμό του φωτισμού, αλλα το χρώμα του "material" του αντικειμένου που περιέχει γενικά διάφορες παραμέτρους που χρειάζεται το εκάστοτε μοντέλο φωτισμού. Παραμέτρους του material θέτουμε με τις glMaterial συναρτήσεις.

lit diffuse cubes (lambert's cosine law)

Εικόνα 2: Φωτισμένοι diffuse κύβοι (lambert's cosine law).

Έτσι, άν θέλουμε οι κύβοι μας να είναι για παράδειγμα κόκκινοι, θα πρέπει να προσθέσουμε στην draw_cube, πρίν το glBegin, τα παρακάτω:

float color[] = {1, 0, 0, 1};
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, color);

Σε απλούς κύβους δεν φαίνεται πολυ καλα η ομαλότητα του φωτισμού απο το gouraud shading, οπότε ας αντικαταστίσουμε τον μεγάλο κύβο με κάτι πιο ενδιαφέρον. Το glut μας δίνει κάποια έτοιμα αντικείμενα που μπορούμε να χρησιμοποιήσουμε για να κάνουμε δοκιμές χωρίς να χρειαστεί να υπολογίσουμε η να φωρτόσουμε απο κάποιο αρχείο πολύπλοκα πολυγωνικά μοντέλα. Οπότε, ας σβήσουμε το πρώτο στοιχείο του cubes array για να ξεφωρτοθούμε τον κεντρικό κύβο, και ας φτιάξουμε μια συνάρτηση που να ζωγραφίζει μια μπλέ τσαγιέρα, την οποία θα την καλέσουμε στην display συνάρτηση μετά το loop των κύβων, αλλα πρίν την glutSwapBuffers (gl3.c):

void draw_teapot(void)
{
	float color[] = {0.1, 0.3, 1, 1};
	glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, color);
	glutSolidTeapot(1.5);
}

Για να μπορέσουμε να προσθέσουμε "γυαλάδα" στις επιφάνειες, ο νόμος του συνημιτόνου του Lambert δεν αρκεί. Πρέπει να μπορούμε να εξομοιώσουμε και την δέσμη φωτώς που ανακλάται με γωνία ανάκλασης ίση της γωνίας πρόσπτωσης απο την επιφάνεια (specular highlight). Αντίθετα με το απλό diffuse (διαχητικό) μοντέλο που περιγράψαμε παραπάνω, το οποίο διαχαίει την ίδια ένταση φωτισμού προς όλες τις κατευθύνσεις, για να υπολογίσουμε αν η ανακλόμενη δέσμη φτάνει στον παρατηριτή, πρέπει να έχουμε άλλο ένα διάνυσμα που να δίχνει προς το σημείο στο οποίο βρίσκεται ο παρατηριτής (διάνυσμα V στο σχήμα 2). Ο Bui-Tuong Phong ανακάλυψε ένα απλό εμπειρικό μοντέλο που προσεγγίζει το φαινόμενο που θέλουμε να πετύχουμε, το οποίο είναι γωνστό ως "Phong reflectance model". Καταρχάς υπολογίζουμε ενα διάνυσμα το οποίο είναι η ανάκλαση του διανύσματος που δίχνει προς τον παρατηριτή (Vr στο σχήμα 2), κατόπιν βρίσκουμε το συνημίτονο της γωνίας ανάμεσα σε αυτό το διάνυσμα και στο διάνυσμα που δίχνει προς το σημείο που βρίσκεται η φωτινή πηγή (L στο σχήμα 2), και υψώνουμε το συνημίτονο σε μια δύναμη. Όσο μεγαλύτερη η δύναμη, τόσο πιο γυαλιστερή φαίνεται η επιφάνεια. Τυπικές τιμές κυμαίνωνται απο 5 έως 100.

Οπότε, για να δώσουμε γυαλίστερή εμφάνιση στην τσαγιέρα μας, θα πρέπει μεσα στην draw_teapot να προσθέσουμε και τα εξής πρίν καλέσουμε την glutSolidTeapot:

float white[] = {1, 1, 1, 1};
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, white);
glMaterialf(GL_FRONT_AND_BACK, GL_SHININESS, 60.0);

Αυτό που κάνουμε είναι να θέσουμε τον εκθέτη του specular στο 60 (αρκετά γυαλιστερό), και να θέσουμε λευκό specular color, με το οποίο πολλαπλασιάζεται η ένταση του specular που υπολογίζει το OpenGL χρησιμοποιώντας το Phong reflectance model.

State Creep

Υπάρχει όμως ενα πρόβλημα. Αν δοκιμάσουμε το παραπάνω και περιστρέψουμε λίγο την σκηνή δεξιά-αριστερά, θα παρατηρίσουμε οτι και οι κύβοι έγιναν γυαλιστεροι! Αυτό γίνεται γιατί όπως έχουμε ξαναπεί, στο OpenGL ότι θέτουμε ισχύει για οτιδίποτε ζωγραφίσουμε απο εκείνο το σημείο και μετα. Έτσι, όταν στην επόμενη κλήση της display πάμε να ζωγραφίσουμε τους κύβους στο loop, τα δυο παραπάνω glMaterial calls συνεχίζουν να υσχύουν. Για να αποφύγουμε αυτο το "state creep", το OpenGL μας παρέχει έναν μηχανισμό ανάλογο με αυτόν της στοίβας των πινάκων που είδαμε στην αρχή του άρθρου. Μπορούμε να καλέσουμε την glPushAttrib για να σώσουμε κάποιο κομάτι του state σε οποιοδήποτε σημείο του προγράμματος μας, και την glPopAttrib για να το επαναφέρουμε αργότερα. Συγκεκριμένα, για να κάνουμε push και pop οτιδήποτε έχει να κάνει με φωτισμό, materials, κλπ. πρέπει να περάσουμε το flag GL_LIGHTING_BIT στην glPushAttrib. Οπότε ας βάλουμε ενα

glPushAttrib(GL_LIGHTING_BIT);
πρίν απο το πρώτο glMaterial call της draw_teapot, και ένα
glPopAttrib();
αμέσως μετά την κλήση της glutSolidTeapot. Το αποτέλεσμα (gl4.c) φαίνεται στην εικόνα 3.

diffuse cube and shiny teapot

Εικόνα 3: Diffuse κύβοι και τσαγιέρα με specular highlights.

Φτάσαμε στο τέλος και του δεύτερου άρθρου αυτής της εισαγωγικής σειράς για προγραμματισμό γραφικών με το OpenGL. Για απορίες ή διευκρινίσεις μπορείτε να με βρείτε στο nuclear@mutantstargoat.com.

Στο επόμενο τεύχος θα δούμε πώς μπορούμε να χρησιμοποιήσουμε texture mapping για να δώσουμε περισσότερο ρεαλισμό και οπτική πολυπλοκότιτα, σε αντικείμενα με λίγα σχετικα πολύγωνα.
Μέχρι τότε, happy hacking!