Cosa è OpenGL ES
OpenGL ES è uno standard industriale per la programmazione grafica 3D su dispositivi mobile. Khronos Group, un conglomerato che include marchi come ATI, NVIDIA ed Intel si preoccupa di definire ed estendere lo standard.
un po' di configurazione
Attualmente esistono 3 versioni di OpenGL ES: 1.0,1.1 e 2.0: in questo articolo ci concentreremo sulle versioni 1.x, poiché più supportate dalla maggiorparte dei dispositivi Android, e dagli emulatori (che supportano in genere soltanto la versione 1.0).
Si consiglia di installare una versione Eclipse (ad esempio Helios 3.6.2) adatta per lo sviluppo con Android, di utilizzare le JDK 1.6 o superiore, il plug-in ADT per eclipse, e le SDK tools r17 di Android.
Al seguente link potete trovare le istruzioni per la configurazione dell'ambiente:
http://developer.android.com/sdk/eclipse-adt.html
Mentre per un'introduzione generale alla programmazione con Android, rimandiamo alla nostra guida android
La camera ed il campo visivo
Consideriamo una situazione classica del mondo reale: lavorare con una videocamera. Quando vogliamo realizzare un video, ciò che facciamo è inquadrare la scena che intendiamo riprendere: all'interno di una scena in genere troviamo diversi oggetti, ognuno dei quali ha una posizione ed un orientamento rispetto alla videocamera.
Anche la videocamera ha però delle caratteristiche come la distanza focale, il campo visivo, ed una posizione rispetto ad un sistema di riferimento.
Infine abbiamo chiaramente bisogno di una sorgente luminosa che illumini il tutto.
Possiamo visualizzare questa scena attraverso la seguente figura:
La scena è molto semplice ma contiene già il primo concetto fondamentale: il view frustum. Con questo termine definiamo il campo di visione della camera, ovvero tutto ciò che la camera riesce a visualizzare dello spazio 3D ed in termini di grafica ciò che verrà visualizzato sullo schermo.
Geometricamente il view frustum è una sorta di "piramide" formata da un piano immediatamente davanti alla camera (Near clip plane), uno più lontano (Far clip plane) e dai piani laterali che li uniscono.
Avendo chiara questa definizione, possiamo proseguire con la definizione degli oggetti in OpenGL ES, detti anche Models. Essi rappresentano le entità del mondo 3D e vengono generalmente definiti attraverso quattro caratteristiche: Geometry, Color, Texture e Material.
La Geometry (geometria) definisce la struttura di un oggetto ed è costituita da un insieme di triangoli. Il colore e le eventuali texture e material invece, definiscono le caratteristiche relative all'aspetto dell'oggetto (es: aspetto roccioso, è chiaro,scuro...). OpenGL ES offre due tipi diversi di sorgenti luminose con vari attributi: si tratta di oggetti matematici con una posizione e/o direzione nel mondo 3D, più attributi come ad esempio il colore. Infine abbiamo parlato finora di mondo 3D, ma OpenGL può ovviamente proiettare il tutto anche su un mondo a due dimensioni.
OpenGL ES 2D
Partendo dalla scena 3D appena vista, OpenGL ES può costruire una visione 2D, dal punto di vista della visuale della camera, attraverso il concetto matematico di proiezione. Esistono due tipi di proiezione utilizzati comunemente nella grafica 3D: parallel projection e perspective projection.
parallel projection
Nella proiezione parallela non ci si cura di quanto sia lontano un oggetto visto dalla prospettiva della camera, l'oggetto avrà sempre la stessa dimensione nell'immagine finale. Questo tipo di proiezione è tipicamente usato nel rendering 2D in OpenGL ES.
perspective projection
Nella proiezione con prospettiva invece, abbiamo il senso della lontanza o vicinanza degli oggetti. I nostri occhi usano questo tipo di proiezione, gli oggetti lontani appaiono più piccoli di fronte alla nostra retina.
Questa è la proiezione tipicamente utilizzata per il rendering 3D in OpenGL ES. In entrambi i casi abbiamo comunque bisogno di un piano di proiezione. Questo piano è quella parte del view frustum che abbiamo chiamato Near clip plane ed ha il suo piccolo sistema di coordinate 2D come mostrato in figura:
E' importante notare che questo sistema di coordinate non è necessariamente fisso cosi come mostrato in figura, ma abbiamo la possibilità di manipolarlo in base alle nostre esigenze.
Ad esempio potremmo istruire OpenGL ES in modo tale che l'origine del sistema di riferimento sia situata nel vertice alto a sinistra e che il near clip plane abbia una larghezza di 480 unità lungo l'asse x e 320 lungo l'asse y. Una volta specificato il view frustum, OpenGL ES proietta ciascun punto di un triangolo attraverso un raggio (ray) che va dal punto al piano di proiezione.
parallel e perspective projection a confronto
La differenza tra proiezione di prospettiva e parallela risiede nel modo in cui la direzione del raggio viene costruita: nel caso di proiezione di prospettiva il raggio va dal punto verso la camera, in quella parallela il raggio è invece perpendicolare al piano di proiezione.
Come vedremo in seguito OpenGL ES esprime le proiezioni sotto forma di matrici di tre tipi:
Model-view matrix
Utilizziamo questa matrice per muovere,ruotare o scalare i punti dei nostri triangoli.Questa matrice è anche utilizzata per specificare posizione e orientamento della camera.
Projection matrix
Questa matrice codifica le proiezioni e quindi il view frustum della nostra camera.
Texture matrix
Utilizzata per manipolare le coordinate delle texture.
Il Rendering Pipeline
Ma come avviene il rendering?
Possiamo pensare ad OpenGL ES come ad una vera e propria macchina a stati, della quale possiamo impostare lo stato corrente. Consideriamo una geometria rappresentata da un triangolo e vediamo ad alto livello, ed in modo semplificato, i passaggi che portano al suo rendering: la rendering pipeline. La figura che segue ne mostra i vari stati, vediamoli più in dettaglio.
Apply Model-View
Il triangolo viene trasformato attraverso la matrice Model-View, questo significa che tutti i suoi punti vengono moltiplicati per questa matrice. Questa moltiplicazione ha come effetto quello di muovere il triangolo.
Apply Projection
Il risultato della trasformazione precedente viene moltiplicato per la matrice di proiezione, quello che otteniamo è la proiezione dei punti 3D in punti 2D sul piano di proiezione.
Apply Lights and Materials
Tra questi due stati precedenti ,o parallelamente ad essi, vengono impostati dei valori per le luci, e materiali per il triangolo, fornendo così ad esso un particolare aspetto grafico.
Apply Clipping & Apply Viewport
A questo punto il triangolo proiettato è visibile sulla nostra “retina”- “videocamera” e trasformato sulle coordinate del framebuffer.
Rasterize
Come passo finale OpenGL ES riempie i pixel del triangolo in base alle luci e alle texture applicate.
Cosa è il Framebuffer?
È doverosa a questo punto una piccola nota sul concetto di framebuffer. Il framebuffer è una memoria buffer della scheda video nella quale vengono memorizzate le informazioni destinate all'output per la rappresentazione di un intero fotogramma sullo schermo.
Abbiamo precedentemente definito la geometria degli oggetti come insieme di triangoli. Un singolo triangolo ha 3 punti definiti nello spazio 3D e per renderizzare un tale triangolo nel framebuffer, OpenGL ES ha bisogno di conoscere le loro coordinate traslate nel sistema di coordinate basato su pixel del framebuffer stesso. Una volta conosciute tali coordinate semplicemente disegna nel framebuffer i pixel contenuti nel triangolo.
OpenGL ES in pratica...
Dopo aver analizzato i vari elementi in gioco, nella prossima parte introdurremo finalmente un po' di codice.
Una demo
Abbiamo visto diversi concetti nei paragrafi precedenti ed introdotto la classe GLSurfaceView
che ci permette di definire una superficie per lo schermo e di poter disegnare su di esso, è arrivato il momento di metterli in pratica e di realizzare una prima semplice demo: un triangolo colorato.
Per disegnare il triangolo dobbiamo definire i 3 punti che lo caratterizzano (vertici). Un vertice è specificato attraverso il tipo float e può avere attributi aggiuntivi come colore e texture anch'essi, come vedremo, specificati attraverso tipi float.
Creiamo un progetto Android con nome GLSurfaceViewDemo
all'interno del nostro ambiente Eclipse configurato, scegliamo una versione di Android (ad esempio la 2.2).
Al termine della creazione definiamo il package it.html.opengles
, che conterrà le nostre classi demo.
Un semplice Renderer
All'interno del package iniziamo con il creare la classe listener per la superficie di disegno di cui abbiamo parlato precedentemente, e concetriamoci sul metodo onSurfaceCreated()
:
public class SimpleRenderer implements Renderer {
private int width;
private int height;
public SimpleRenderer(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
// ...
}
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
Log.d("GLSurfaceViewDemo", "surface changed: " + width + "x" + height);
}
@Override
public void onDrawFrame(GL10 gl) {
// ...
}
}
Il codice che andiamo ad inserire in questo metodo è quello che ci permette di definire il view frustum ed il tipo di proiezione degli oggetti sul near clip plane
:
gl.glClearColor(0, 0, 0, 1);
gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
gl.glViewport(0, 0, width, height);
gl.glMatrixMode(GL10.GL_PROJECTION);
gl.glLoadIdentity();
gl.glOrthof(0, width, 0, height, 1, -1);
Con la prime due istruzioni impostiamo il colore di sfondo, mentre con la terza definiamo il viewport attraverso il metodo glViewport()
.
definizione del viewport
Soffermiamoci un attimo su questo metodo. OpenGL ES utilizza il Viewport per traslare le coordinate dei punti proiettati sul near clip plane nelle coordinate pixel del framebuffer. Attraverso questo metodo possiamo dire ad OpenGL ES se usare l'intero framebuffer oppure una parte di esso, la definizione generale del metodo è la seguente:
GL10.glViewport(int x, int y, int width, int height)
Le coordinate x ed y specificano il vertice in basso a sinistra del Viewport nel framebuffer, mentre width
ed height
specificano la dimensione in pixel del Viewport. E' importante notare che OpenGL ES assume che il sistema di coordinate del framebuffer abbia la sua origine nel vertice in basso a sinistra dello schermo. Generalmente si utilizzano i valori x=0
ed y=0
e width
ed height
sulla risoluzione dello schermo, in modo da utilizzare la modalità full-screen.
definizione della matrice di proiezione
Dopo aver definito il Viewport proseguiamo con il definire la matrice di proiezione. Abbiamo detto precedentemente che OpenGL ES utilizza 3 tipi di matrici: projection matrix
, model-view matrix
e texture matrix
. Per la gestione di queste matrici abbiamo a disposizione una coppia di metodi. Prima di utilizzarli però, dobbiamo specificare quale,tra i tipi possibili di matrice, intendiamo utilizzare;lo facciamo attraverso il seguente metodo:
GL10.glMatrixMode(int mode)
Il parametro mode può essere: GL10.GL_PROJECTION
, GL10.GL_MODELVIEW
o GL10.GL_TEXTURE
. Dai nomi si può facilmente intuire la matrice che rappresentano.
Qualsiasi successiva chiamata ai metodi di manipolazione, avrà come target la matrice che abbiamo scelto con il metodo glMatrixMode()
. Possiamo cambiare la matrice attiva con una nuova chiamata a questo metodo. Il matrix mode è uno degli stati di OpenGL ES che perdiamo nell'ambito del context loss, se la nostra applicazione va in pausa e viene successivamente ripristinata. Per manipolare la matrice di proiezione possiamo invocare il metodo in questo modo:
gl.glMatrixMode(GL10.GL_PROJECTION);
Per utilizzare il tipo di matrice correntemente attiva come matrice di proiezione parallela (detta parallel o orthographic projection), utilizziamo invece il metodo:
GL10.glOrthof(
int left, int right,
int bottom, int top,
int near, int far
)
Notiamo che prima di questo metodo abbiamo invocato glLoadIdentity()
, il motivo è legato al fatto che quest'ultimo imposta la matrice corrente sulla matrice identità evitando che la chiamata a glOrthof()
produca un prodotto della matrice di proiezione per se stessa, cosa che non vogliamo. Infatti molti metodi di OpenGL ES ci permettono di manipolare la matrice correntemente attiva non impostandone direttamente il valore, ma costruendo una matrice temporanea che verrà poi moltiplicata per la matrice corrente, e glOrthof()
è uno di questi.
Con glLoadIdentity()
invece facciamo in modo che glOrthof()
moltiplichi la matrice attiva ,che abbiamo impostato, per la matrice identità, sapendo che il prodotto di una qualsiasi matrice per la matrice identità restituisce la matrice stessa.
il sistema di coordinate
OpenGL ES ha un sistema di coordinate standard nel quale l'asse positivo x è diretto verso destra, quello positivo y verso l'alto, e quello positivo z verso di noi che guardiamo lo schermo.
Con il metodo glOrthof()
abbiamo definito il view frustum della nostra proiezione parallela in questo sistema di coordinate. Come possiamo vedere dalla figura che segue, il view frustum di una proiezione parallela è un box, e possiamo interpretare i parametri del metodo glOrthof()
come la specifica di due dei vertici del box:
Il near clip plane del nostro view frustum (Viewport) deve essere mappato sullo schermo del nostro device.
Ad esempio nel caso della modalità a tutto schermo (full-screen viewport), con una ampiezza che va da (0,0) a (480,320), il vertice basso di sinistra (bottom-left) del near clip plane dovrebbe essere mappato con il vertice basso di sinistra dello schermo del device e quello in alto a destra (top-right) con l'analogo dello schermo.
Ciò che stiamo facendo è mappare le coordinate di questi due punti per lavorare in un sistema di coordinate a pixel. Ad esempio per specificare il Viewport per uno schermo con risoluzione 480x320:
gl.glOrthof(0, 480, 0, 320, 1, -1);
ricordiamo che in 2D la coordinata Z viene chiaramente ignorata. La parte visibile del nostro sistema di coordinate, in questo caso, va da (0,0,1) a (480,320,-1), e qualsiasi punto specifichiamo all'interno di questo box sarà visibile sullo schermo del device.
il metodo di disegno
Definito il view frustum, considerimo il codice da inserire nel metodo onDrawFrame()
che ci permette di disegnare il triangolo:
int VERTEX_SIZE = (2 + 4) * 4;
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(3 * VERTEX_SIZE);
byteBuffer.order(ByteOrder.nativeOrder());
FloatBuffer vertices = byteBuffer.asFloatBuffer();
vertices.put(new float[] {
0.0f, 0.0f, 1,
0, 0, 1,
319.0f, 0.0f, 0,
1, 0, 1,
160.0f, 479.0f, 0,
0, 1, 1
});
vertices.flip();
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
vertices.position(0);
gl.glVertexPointer(2, GL10.GL_FLOAT, VERTEX_SIZE, vertices);
vertices.position(2);
gl.glColorPointer(4, GL10.GL_FLOAT, VERTEX_SIZE, vertices);
gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3);
OpenGL ES si aspetta in input la definizione del nostro triangolo sotto forma di array. Giacchè però OpenGL ES è a tutti gli effetti una API in linguaggio C "wrappata" in java, non potremo utilizzare direttamente gli array Java ma dovremo passare attraverso un buffer NIO: blocchi di memoria di bytes consecutivi.
Questo significa che la memoria non sarà allocata nell'Heap della JVM ma nella memoria nativa. Il buffer si costruisce attraverso le righe di codice:
int VERTEX_SIZE = (2 + 4) * 4;
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(3 * VERTEX_SIZE);
byteBuffer.order(ByteOrder.nativeOrder());
FloatBuffer vertices = byteBuffer.asFloatBuffer();
Spiegheremo tra poco il valore assegnato a VERTEX_SIZE
, per il momento soffermiamoci sulla costruzione del buffer.
Con allocateDirect()
abbiamo allocato un buffer in grado di contenere un numero di byte pari a 3 * VERTEX_SIZE
con la certezza che l'ordine dei byte è lo stesso utilizzato dalla CPU. Un buffer NIO ha 3 attributi:
- Capacità: Il numero di elementi totali che può contenere.
- Posizione: La posizione corrente del prossimo elemento che è possibile leggere o scrivere.
- Limite: L'indice dell'ultimo elemento incrementato di uno.
dopo aver definito il buffer, decidiamo di ottenere da esso un buffer di float perchè intendiamo lavorare con questo tipo di dato. Dobbiamo a questo punto specificare i vertici ed i colori ad essi associati del triangolo, questa operazione si realizza attraverso un array di float:
vertices.put(new float[] {
0.0f, 0.0f, 1,
0, 0, 1,
319.0f, 0.0f, 0,
1, 0, 1,
160.0f, 479.0f, 0,
0, 1, 1
});
Dunque, abbiamo per ciascun punto due coordinate in 2D (2 numeri float) più 4 float per la definizione del colore RGBA che vogliamo per il vertice. Ciascun float prende 4 byte quindi il totale di byte per singolo vertice è:
VERTEX_SIZE = (2 + 4) * 4
ecco spiegato il valore di VERTEX_SIZE
: il numero di byte totali per contenere i float delle coordinate del singolo punto più la defnizione del suo colore.
Abbiamo 3 punti (3 vertici) e quindi si comprende perchè in allocateDirect()
VERTEX_SIZE
sia moltiplicato per 3.
Leggiamo l'array di float passato in input al metodo put()
,ed identifichiamo il punto (0,0) ed il suo colore nei primi 6 elementi:
0.0f, 0.0f, 1, 0, 0, 1
il punto 319.0f,0.0f
, con il suo colore nei successivi 6, ed il punto 160.0f,479.0f
, con il suo colore negli ultimi 6.
Definiti tutti i vertici usiamo il metodo flip()
per posizionare il buffer sull'elemento di indice 0,il primo dell'array.
il disegno del triangolo!
Siamo pronti per la parte finale, il disegno del triangolo:
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
vertices.position(0);
gl.glVertexPointer(2, GL10.GL_FLOAT, VERTEX_SIZE, vertices);
vertices.position(2);
gl.glColorPointer(4, GL10.GL_FLOAT, VERTEX_SIZE, vertices);
gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3);
Insieme ai vertici vogliamo renderizzare,come detto, anche il colore. Specifichiamo questa intenzione ad OpenGL ES attraverso il codice:
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
adesso OpenGL ES sa che deve aspettarsi informazioni su vertici e colori. Dobbiamo solo specificare da che punto si iniziano a leggere i vertici e da che punto i colori. Dalla posizione 0 diciamo di iniziare a leggere le coordinate dei vertici:
vertices.position(0);
gl.glVertexPointer(2, GL10.GL_FLOAT, VERTEX_SIZE, vertices);
Dalla posizione 2 i colori:
vertices.position(2);
gl.glColorPointer(4, GL10.GL_FLOAT, VERTEX_SIZE, vertices);
grazie a queste informazioni iniziali OpenGL ES sarà in grado di leggere le coordinate di ciascun vertice e il suo colore associato. Con la seguente ultima istruzione riusciamo invece a renderizzare il triangolo:
gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3);
la classe Activity
Abbiamo quindi terminato il listener per il rendering, e ci rimane soltanto la definizione della classe che realizza la nostra Activity
e superficie di disegno. La classe è molto semplice, estende Activity
e, all'interno del metodo onCreate()
, costruisce l'oggetto GLSurfaceView
sul quale viene associato l'oggetto listener della classe che abbiamo appena definito; come risoluzione viene utilizzata una 480x320:
public class GLSurfaceViewDemoActivity extends Activity {
private GLSurfaceView glView;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
glView = new GLSurfaceView(this);
glView.setRenderer(new SimpleRenderer(480, 320));
setContentView(glView);
}
@Override
public void onResume() {
super.onPause();
glView.onResume();
}
@Override
public void onPause() {
super.onPause();
glView.onPause();
}
}
e il codice completo della classe listener sarà:
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;
import android.opengl.GLSurfaceView.Renderer;
import android.util.Log;
public class SimpleRenderer implements Renderer {
private int width;
private int height;
public SimpleRenderer(int width, int height) {
this.width = width;
this.height = height;
}
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
gl.glClearColor(0, 0, 0, 1);
gl.glClear(GL10.GL_COLOR_BUFFER_BIT);
gl.glViewport(0, 0, width, height);
gl.glMatrixMode(GL10.GL_PROJECTION);
gl.glLoadIdentity();
gl.glOrthof(0, width, 0, height, 1, -1);
}
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
Log.d("GLSurfaceViewDemo", "surface changed: " + width + "x" + height);
}
@Override
public void onDrawFrame(GL10 gl) {
int VERTEX_SIZE = (2 + 4) * 4;
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(3 * VERTEX_SIZE);
byteBuffer.order(ByteOrder.nativeOrder());
FloatBuffer vertices = byteBuffer.asFloatBuffer();
vertices.put(
new float[] { 0.0f, 0.0f, 1, 0, 0, 1, 319.0f, 0.0f, 0, 1,
0, 1, 160.0f, 479.0f, 0, 0, 1, 1 });
vertices.flip();
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_COLOR_ARRAY);
vertices.position(0);
gl.glVertexPointer(2, GL10.GL_FLOAT, VERTEX_SIZE, vertices);
vertices.position(2);
gl.glColorPointer(4, GL10.GL_FLOAT, VERTEX_SIZE, vertices);
gl.glDrawArrays(GL10.GL_TRIANGLES, 0, 3);
}
}
A questo punto potete definire un emulatore, ad esempio per Android 2.2, avviarlo e lanciare la nostra piccola demo per il disegno di un triangolo come applicazione Android. Dovreste ottenere un'immagine simile alla seguente:
Conclusioni
In questo articolo abbiamo mosso i primi passi nel mondo OpenGL ES e più in generale del game development in ambiente Android, abbiamo compreso concetti fondamentali come view frustum e proiezioni, ed abbiamo infine sperimentato il tutto con una semplice demo concentrandoci sul solo spazio 2D.
Nei prossimi articoli proseguiremo col vedere l'utilizzo delle texture e delle trasformazioni, imparando le tecniche per dare un particolare aspetto alle nostre geometrie e per ruotarle, traslarle e ridimensionarle.