Riassunto di un travaglio
Anno 1995, Sun e SGI pianificano lo sviluppo di un'API 3D per Java. L'API è presentata al JavaOne del 1999. Gloria, onori, prospettive, Sun procede a spron battuto sulla strada dello sviluppo. Inarrestabile? Be', ci pensa il NASDAQ. La storia è nota. A cavallo del nuovo millennio Sun di sgrana i denti sul panino di Wall Street e a farne le spese sono i comparti minori. Java3D entra in coma alle soglie della versione 1.3.2. Peccato. Programmare in 3D non era mai stato così facile. Francamente di Java3D non si sa più nulla fino alla metà del 2004. A quel punto, Sun decide di rilasciare il codice sorgente. Contribuisca chi voglia. O quasi. OpenSource alla Sun, esiste un team di sviluppo che si occupa di portare avanti il destino dell'API, aperto a suggerimenti, idee. Il progetto ha comunque un solo leader utile. Piaccia o no, la cosa funziona. Da sei mesi a questa parte esce un upgrade a settimana, circa. Il piano prevede il rilascio di una versione 1.3.2 che introduca le capacità previste prima del tracollo finanziario (secoli fa). L'obiettivo è Java3D 1.4, nel 2005. La questione non è marginale. Attualmente Java3D supporta un 3D vecchio stampo. Notabilmente, mancano pixel shader e stencil buffer. Riflessi e ombre in tempo reale, per riassumere. Esistono bump-map e normal-map, il che da la sensazione di una torta riuscita per metà. Sempre tracciando la strada futura, è bene sottolineare che il progetto è stato ridimensionato. In origine, Java3D rappresentava un'astrazione in grado di usare indifferentemente OpenGL o DirectX. Oggi si dichiara apertamente che la "comunità" si occupa di OpenGL. A meno di sorprese, dunque, Java3D 1.4 userà OpenGL. Già la versione attuale (1.3.2-beta2), considera DirectX un minore.
OpenGL e Java3D
OpenGL è una libreria di rendering. Un mezzo per comunicare con l'hardware destinato a riprodurre elementi grafici. La parte di OpenGL esposta al programmatore è l'Hardware Abstraction Layer (HAL). Un insieme di comandi che il programmatore impartisce a colpi di codice. OpenGL comunica con un driver di periferica compatibile che traduce in lingua compresa dalla scheda video.
La prospettiva HAL OpenGL è strettamente procedurale. La programmazione è risolta in un cumulo di raffinati comandi ordinati in successione. Il che è un ottimo modo per smarrire la proverbiale bussola. Qualsiasi programma che proponga un mondo virtuale in tre dimensioni, usando OpenGL o DirectX, usa delle strutture più generali per semplificare lo sviluppo. Si può fare partendo da zero, o ci si può appoggiare a dei framework precostruiti. Questo è Java3D. Un insieme di librerie che, basandosi su OpenGL (o DirectX), forniscono strumenti per realizzare applicazioni che facciano uso della grafica in tre dimensioni. Questi strumenti sono rappresentazioni di entità più o meno generali ed astratte utili alla composizione di un universo virtuale. Facciamo un esempio. In OpenGL manca il concetto di "oggetto tridimensionale". In verità non esistono nemmeno i punti. In Java3D, oltre a punti, vettori e matrici, abbiamo a disposizione il tipo javax.media.j3d.Shape3D che rappresenta una figura tridimensionale. Una figura tridimensionale è un oggetto che possiede un set di punti, vettori e coordinate texture ed un aspetto (javax.media.j3d.Appearance). Una volta creato, può essere inserito in un universo virtuale (javax.media.j3d.VirtualUniverse). In definitiva, è un livello in più di astrazione in una prospettiva orientata agli oggetti.
Organizzazione
Non è sufficiente avere a disposizione dei tipi che rappresentino entità 3D per poter creare dei mondi. Serve un mezzo per trasferire le informazioni rappresentate da questi oggetti all'hardware. Nel caso di un universo dinamico, occorre che il mondo sia in gradi di cambiare e in risposta ad eventi generati dall'utente e in via autonoma. Anche, insomma, il 3D è un regno ciclico. Java3D mette a disposizione un ciclo occulto di rendering. L'architettura è complicata e, per molti aspetti, ignota (il codice sorgente di Java3D è tutto fuorché elementare). Il mondo virtuale, inteso come insieme di oggetti di alto livello, è rappresentato come un "grafo diretto aciclico". Forse. Un grafo è la generalizzazione di un albero. Un albero è una struttura dati composta di nodi e relazioni gerarchiche padre - figlio tra questi nodi. Il nodo di un albero può, per definizione, avere un solo padre. Un grafo è un albero in cui quest'ultimo limite è rimosso. Vale a dire, esistono figli di più padri. In teoria i nodi della struttura usata da Java3D possono avere più padri. Ma una struttura dati di questo tipo non è considerata valida durante l'esecuzione. A meno di arzigogolare, quindi, il "grafo diretto aciclico" non è un grafo ma un albero. Vediamo gli elementi di questo albero. In cima troviamo:
SceneGraphObject (abstract)
Tutti i nodi dell'albero sono oggetti di tipo SceneGraphObject. La classe è soggetta ad uso implicito. I figli di SceneGraphObject sono:
NodeComponent (abstract) Node (abstract)
NodeComponent rappresenta gli elementi accessori al contenuto di altri nodi. Node è il succo dell'albero. Gli elementi appartenenti all'albero sono tutti figli di Node. Gli altri, come detto, sono accessori all'albero. La famiglia Node annovera:
Group / Leaf
Group Leaf (abstract)
Un Group è un nodo che accetta dei figli. Un Leaf è un nodo terminale. I figli di Group sono:
BranchGroup: un nodo di contenimento. È l'unico nodo che possa essere disconnesso dopo essere stato aggiunto ad un Locale. È l'unico nodo che possa essere aggiunto ad un gruppo già connesso ad un Locale.
OrderedGroup: un nodo in cui è assicurato l'ordine di visualizzazione dei suoi figli. Normalmente la scelta dell'ordine di visualizzazione è stabilita internamente da Java3D.
SharedGroup: un nodo che rappresenta un ramo dell'albero i cui figli sono condivisi da altri nodi (di tipo Link). Il meccanismo è simile a quello dei puntatori ad un oggetto condiviso.
Switch: un nodo che è in grado di stabilire quali tra i suoi figli debbano essere visualizzati.
TransformGroup: un nodo in grado di applicare una trasformazione che influenza il sistema di coordinate dei suoi figli. Rotazione, spostamento, aumento o riduzione delle dimensioni. Le informazioni sul tipo di trasformazione da applicare sono contenute in un oggetto Transform3D.
ViewSpecificGroup: un gruppo la cui visualizzazione dipende dalla vista attiva. Un universo in Java3D può avere più di un "punto di vista". A seconda di quale sia attivo, questo gruppo è visibile o nascosto.
Tra i figli di Leaf emergono:
Background: sfondo dell'universo virtuale. Può essere un colore, un'immagine statica o un'immagine proiettata su una sfera unitaria (effetto skydome).
Behavior: definisce un comportamento. Un comportamento è quello che suggerisce il nome. Una o più azioni attivate in seguito ad uno stimolo. Ad esempio, la navigazione all'interno dell'universo virtuale è un comportamento di un oggetto View attivato dall'interazione utente. Ma anche il semplice calcolo dei frame per secondo è trattabile come un Behavior.
Light: una luce, diffusa, a punto, direzionale.
Shape3D: il nodo rappresenta un oggetto tridimensionale. Case, alberi, terreni, tutto si infila in uno Shape3D. Uno Shape3D è costituito di un oggetto Geometry, che definisce vertici, normali, colori e coordinate texture di un oggetto, ed un Appearance, che stabilisce l'aspetto complessivo della geometria.
ViewPlatform: il nodo che ingloba una vista sull'universo (View).
Canvas3D, VirtualUniverse, Locale e l'essere vivi
Ciò che si vede di un universo virtuale in tre dimensioni è la sua proiezioni su un piano bidimensionale. Meglio ancora, è la proiezione di ciò che si vedrebbe se lo osservassimo da un certo punto di vista. Da qui ricaviamo che per costruire un mondo in 3D ci servono: la rappresentazione dell'universo, un punto di vista, uno schermo di proiezione. In Java3D un universo virtuale è un javax.media.j3d.VirtualUniverse. Una vista è un oggetto javax.media.j3d.View. Per inserire una vista in un universo abbiamo bisogno di un javax.media.j3d.ViewPlatform, una piattaforma per la vista, letteralmente. La vista, collegata alla sua piattaforma, deve poter essere posizionate in un punto arbitrario dell'universo. Abbiamo accennato al fatto che lo spostamento in 3D si realizza attraverso una trasformazione operata da un TrasformGroup. Dunque la vista (View) è collegata ad una piattaforma (ViewPlatform) inserita in un gruppo di trasformazione (TransformGroup). Per poter proiettare l'universo dal punto in cui si trova, una vista ha bisogno inoltre di un schermo 3D. Lo schermo è incapsulato in un Canvas3D che è, insieme, piano di proiezione per la vista e componente inseribile in un'interfaccia AWT o Swing. Resta da capire come collegare il gruppo di trasformazione a cui appartiene la piattaforma visiva all'universo virtuale. Per posizionare un oggetto in uno spazio di coordinate occorre un origine che dia significato ai valori di quelle coordinate. In Java3D, questa origine è rappresentata da un sistema di coordinate ad alta risoluzione, inglobato in un oggetto javax.media.j3d.Locale. Un VirtualUniverse può avere più di un Locale associato ma, in genere, ne basta uno. Un Locale fornisce l'appiglio per la connessione di un albero di componenti attraverso il metodo addBranchGraph(BranchGroup). Quando un gruppo è connesso ad un Locale, direttamente (solo un BranchGroup) o indirettamente (un qualsiasi nodo connesso in via diretta o mediata ad un BranchGroup assegnato ad un Locale), il gruppo diventa "vivo". Il contenuto di un gruppo vivo è anche attivo, nel senso che il suo contenuto è visualizzato (ammesso che contenga qualcosa di visibile) e partecipa al divenire dell'universo virtuale (è il caso dei Behavior, dei nodi Light, Fog eccetera). Riassumiamo in codice il necessario alla creazione di un universo 3D.
Creazione di un Canvas3D:
java.awt.GraphicsConfiguration cfg3D =
java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().
getDefaultScreenDevice().
getBestConfiguration(new GraphicsConfigTemplate3D());
Canvas3D canvas = new Canvas3D(cfg3D);
Costruzione di una piattaforma per la vista:
ViewPlatform viewPlatform = new ViewPlatform();
Creazione di una vista:
View view = new View();
Impostazione dei parametri fisici della vista (in questo caso usiamo i predefiniti):
view.setPhysicalBody(new PhysicalBody()); view.setPhysicalEnvironment(new PhysicalEnvironment());
Connessione della vista alla piattaforma visiva:
view.attachViewPlatform(viewPlatform);
Connessione della vista al Canvas3D:
view.addCanvas3D(canvas);
Creazione di un gruppo di trasformazione per muovere la piattaforma visiva:
TransformGroup viewTransform = new TransformGroup();
Inserimento della piattaforma visiva tra i figli del gruppo di trasformazione:
viewTransform.addChild(viewPlatform);
Creazione di un gruppo di contenimento per il gruppo di trasformazione della vista, da inserire in un Locale:
BranchGroup viewRoot = new BranchGroup();
Inserimento del gruppo di trasformazione della vista nel gruppo di contenimento viewRoot:
viewRoot.addChild(viewTransform);
Creazione di un universo virtuale:
VirtualUniverse universe = new VirtualUniverse();
Creazione di un Locale assegnato all'universo virtuale:
Locale locale = new Locale(universe);
Connessione del gruppo di contenimento "viewRoot" al Locale:
locale.addBranchGraph(viewRoot);
Al termine di questi passaggi, il Canvas3D è pronto per visualizzare il nostro mondo in 3D. È sufficiente aggiungerlo ad una finestra e visualizzare quella finestra. Ecco una classe ad hoc:
package it.j3dapp;
import javax.vecmath.*;
import javax.media.j3d.*;
import javax.swing.*;
public class MainClass {
public static void main(String...args) {
SwingUtilities.invokeLater(new Runnable() {
public void run() {
setupAndShow();
}
});
}
private static void setupAndShow() {
java.awt.GraphicsConfiguration cfg3D =
java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().
getDefaultScreenDevice().
getBestConfiguration(new GraphicsConfigTemplate3D());
Canvas3D canvas = new Canvas3D(cfg3D);
ViewPlatform viewPlatform = new ViewPlatform();
View view = new View();
view.setPhysicalBody(new PhysicalBody());
view.setPhysicalEnvironment(new PhysicalEnvironment());
view.attachViewPlatform(viewPlatform);
view.addCanvas3D(canvas);
TransformGroup viewTransform = new TransformGroup();
viewTransform.addChild(viewPlatform);
BranchGroup viewRoot = new BranchGroup();
viewRoot.addChild(viewTransform);
VirtualUniverse universe = new VirtualUniverse();
Locale locale = new Locale(universe);
locale.addBranchGraph(viewRoot);
JFrame frame = new JFrame("Java3D In Action");
frame.add(canvas);
frame.setSize(640, 480);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setVisible(true);
}
}
ed il suo risultato:
Che non si veda un bel niente, poco importa. Conta la comprensione del sistema. Si creano nodi, coi nodi si forma un albero, o il ramo di un albero, il tutto si compone in un BranchGroup che si connette ad un Locale. Dalla connessione in poi, salvo sviste, il contenuto del BranchGroup è pronto per partecipare all'attività in tre dimensioni.
Sistemi di coordinate e face culling
Il sistema di coordinate di Java3D è destrorso. Alzate la mano destra, puntando il pollice verso l'alto. Col pollice alzato, puntate l'indice contro voi stessi. Ora alzate il medio a formare un angolo retto con l'indice. A meno di un tragico giradito, quello è il sistema di coordinate di Java3D: i valori positivi dell'asse Y aumentano nella direzione in cui punta il pollice, i valori positivi dell'asse Z aumentano nella direzione in cui punta l'indice, i valori positivi dell'asse X aumentano nella direzione in cui punta l'indice. Un oggetto tridimensionale è costruito come un insieme di facce. Ogni faccia è rappresentata da un poligono, ed un poligono dai vertici che lo definiscono. Nella maggioranza dei casi, i poligoni in questione sono triangoli e gli oggetti sono costruiti come insiemi di triangoli. Un poligono in tre dimensioni ha due facce, anteriore e posteriore. Normalmente la faccia posteriore di un poligono non è visibile (back face culling), il che ci porta a dover stabilire quale faccia di un poligono sia "anteriore" e quale "posteriore". Come per il sistema di coordinate, è ancora una questione di dita. Prendiamo tre vertici che definiscano un triangolo.
P0 (0, 0, 0) P1 (1, 0, 0) P2 (1, 1, 0)
Nel sistema di coordinate di Java3D, questi tre vertici sono rappresentabili come segue:
Nel triangolo composto dai punti (P0, P1, P2), presi in quest'ordine, qual'è la faccia anteriore e qual'è quella posteriore? Idealmente seguite il percorso che va dal punto P0 al punto P2:
Ora posizionare la mano destra in modo tale che chiudendo le dita verso il palmo queste compiano un movimento nello stesso senso rotatorio che compireste seguendo il percorso da P0 a P2. Il pollice indica la faccia anteriore (nel caso in figura, la stessa faccia che osservate guardando lo schermo). In Java3D la faccia visibile è, per impostazione predefinita, la faccia anteriore. Quale faccia debba essere nascosta è determinato da un attributo degli oggetti della classe PolygonAttributes. PolygonAttributes è un NodeComponent (un elemento accessorio) e possiede un metodo, setCullFace(int) che determina, appunto, quale faccia di un poligono sia visibile e quale no. I valori ammessi sono PolygonAttributes.CULLFRONT, PolygonAttrbiutes.CULLBACK e PolygonAttributes.CULLNONE. Il valore predefinito è PolygonAttributes.CULLBACK. Gli attributi di un PolygonAttributes sono associati ad un oggetto Appearance. Appearance definisce le proprietà dell'aspetto di una figura Shape3D. Prima dell'aspetto, però, vediamo come definire la geometria di un oggetto.
Geometria di un oggetto
Abbiamo accennato al fatto che, di norma, una figura tridimensionale è definita come un insieme di triangoli. Il perché sta nel vostro PC. Pare infatti che questo tipo di poligoni siano particolarmente appetibili alle GPU (Graphics Processor Unit, il processore della scheda video). Tanto vale accontentarle. Definiamo un quadrato come la composizione di due triangoli rettangoli:
Stabilendo che il lato del nostro quadrato sia lungo 1.0, e che il punto P0 abbia coordinate (0, 0, 0), le coordinate di tutti i vertici risultano:
P0 (0, 0, 0) P1 (1, 0, 0) P2 (1, 1, 0) P3 (0, 1, 0)
Come "somma" di due triangoli, il nostro quadrato è definito dalle facce:
(P0, P1, P2) e (P1, P2, P3)
Trattiamo il modo meno efficiente ma più semplice possibile per definire i due triangoli. In Java3D possiamo definire la geometria di una figura composta di triangoli usando un oggetto TriangleArray. Il costruttore che ci interessa richiede due argomenti:
TriangleArray(int vertexCount, int vertexFormat)
vertexCount è il numero di vertici che inseriremo in questo TriangleArray. Deve essere un multiplo di tre. vertexFormat è, come suggerisce il nome, il formato dei vertici. Il formato si costruisce usando l'operatore OR sui bit ( | ) ed alcune bandiere definite come costanti della classe GeometryArray. L'obiettivo è informare l'oggetto TriangleArray circa il tipo di informazioni che definiremo per i suoi vertici. Sicuramente per un vertice occorre stabilire delle coordinate. Il vertice di una figura tridimensionale può avere inoltre un "normale". In questo caso "normale" sta per "vettore normale alla superficie". Un vettore è un insieme di direzione e lunghezza. In Java3D i vettori sono definiti come oggetti javax.vecmath.Vector[N][f/d]. Ad esempio:
Vector3f v = new Vector3f(0, 2, 0);
I valori (2, 2, 0) non rappresentano il punto x = 2, y = 2, z = 0, nello spazio tridimensionale. Sono i valori che determinano lunghezza e direzione del vettore. In questo caso il vettore è perpendicolare al piano XZ "punta" nella direzione in cui i valori di Y crescono (in Java3D, verso l'alto) ed ha lunghezza 2. I vettori hanno una quantità d'usi nella matematica usata per la programmazione in 3D. Noi usiamo i vettori per determinare come la nostra figura geometrica rifletta o emetta luce. Brevemente, i vertici della figura emettono o riflettono luce nella direzione dei vettori associati. I punti che abbiamo definito sopra costituiscono un quadrato complanare al piano YX. Supponendo di osservare la faccia anteriore del quadrato, affinché i suoi vertici emettano o riflettano luce nella direzione di chi osservi, dovremo definire dei vettori che "puntino" in direzione dell'osservatore.
V0 (0, 0, 1) V1 (0, 0, 1) V2 (0, 0, 1) V3 (0, 0, 1)
Notate l'assenza di una relazione tra i valori dei componenti dei vettori e le coordinate dei punti a cui sono associati. Un vettore non è un punto: è una direzione ed una lunghezza (detta anche magnitudine o norma del vettore). Avendo deciso di usare coordinate (necessarie) e normali, il formato dei vertici del TriangleArray sarà:
int vertexFormat = GeometryArray.COORDINATES | GeometryArray.NORMALS
Dovendo costruire un quadrato come composizione di due triangoli, useremo 6 vertici. L'istruzione per costruire il nostro TriangleArray è:
TriangleArray geom = new TriangleArray( 6, GeometryArray.COORDINATES | GeometryArray.NORMALS);
I sei punti ed i sei vettori che useremo sono:
Point3f[] vertices = {
new Point3f(0, 0, 0),
new Point3f(1, 0, 0),
new Point3f(1, 1, 0),
new Point3f(0, 0, 0),
new Point3f(1, 1, 0),
new Point3f(0, 1, 0)
};
Vector3f[] normals = {
new Vector3f(0, 0, 1),
new Vector3f(0, 0, 1),
new Vector3f(0, 0, 1),
new Vector3f(0, 0, 1),
new Vector3f(0, 0, 1),
new Vector3f(0, 0, 1),
};
Per assegnare punti e vettori, la classe GeometryArray, a cui appartiene TriangleArray, definisce i metodi setCoordinates e setNormals. I due metodi richiedono un array di punti Point3f e di vettori Vector3f, che abbiamo definito sopra, ed un indice, che è l'indice del primo punto da cui iniziare per ricavare i vertici. Avendo definito i soli punti e normali necessari, l'indice da usare è 0 (zero).
geom.setCoordinates(0, vertices); geom.setNormals(0, normals);
Ora abbiamo le informazioni sulla geometria del nostro quadrato. Manca l'aspetto.
Appearance app = new Appearance(); Material mat = new Material(); mat.setEmissiveColor(new Color3f(1, 0, 0)); app.setMaterial(mat);
Qui abbiamo creato un oggetto Appearance (aspetto) a cui abbiamo associato un materiale (Material). Material serve a definire ciò che il nome suggerisce: le proprietà del materiale di cui è fatto un oggetto tridimensionale. Notate che il materiale è solo una parte dell'aspetto. Noi abbiamo definito un materiale luminescente rosso acceso. Con aspetto e geometria, abbiamo concluso la costruzione del nostro oggetto. Ora è sufficiente comporre uno Shape3D:
Shape3D shape = new Shape3D(geom, app);
Shape3D è un nodo. Un Leaf, ad essere precisi. In quanto nodo, può essere aggiunto ad un gruppo. Quando il gruppo a cui appartenga questo Shape3D sarà connesso ad un Locale, questo Shape3D apparterrà ad un universo virtuale. Secondo le proprietà definite, esso sarà anche visibile. Per comodità, costruiamo una classe che estenda BranchGroup (il gruppo di puro contenimento), ed aggiungiamo a questo BranchGroup la nostra figura durante la costruzione:
package it.j3dapp;
import javax.vecmath.*;
import javax.media.j3d.*;
public class SimpleGeometryGroup extends BranchGroup {
public SimpleGeometryGroup() {
Point3f[] vertices = {
new Point3f(0, 0, 0),
new Point3f(1, 0, 0),
new Point3f(1, 1, 0),
new Point3f(0, 0, 0),
new Point3f(1, 1, 0),
new Point3f(0, 1, 0)
};
Vector3f[] normals = {
new Vector3f(0, 0, 1),
new Vector3f(0, 0, 1),
new Vector3f(0, 0, 1),
new Vector3f(0, 0, 1),
new Vector3f(0, 0, 1),
new Vector3f(0, 0, 1),
};
int vertexFormat = GeometryArray.COORDINATES | GeometryArray.NORMALS;
TriangleArray geom = new TriangleArray(vertices.length, vertexFormat);
geom.setCoordinates(0, vertices);
geom.setNormals(0, normals);
Material mat = new Material();
mat.setEmissiveColor(new Color3f(1, 0, 0));
Appearance app = new Appearance();
app.setMaterial(mat);
Shape3D shape = new Shape3D(geom, app);
addChild(shape);
}
}
Riprendendo la classe MainClass su riportata, aggiungiamo il nostro BranchGroup all'universo virtuale. Aggiungiamo al Locale usato un secondo BranchGroup, allo scopo di mantenere separato il ramo della vista da quello degli oggetti che compongono il mondo 3D. Non è strettamente necessario ma aiuta a mantenere fisicamente distinte parti che siano logicamente diverse. Dopo la linea:
locale.addBranchGraph(viewRoot);
inseriamo:
BranchGroup objectRoot = new BranchGroup(); objectRoot.addChild(new SimpleGeometryGroup()); locale.addBranchGraph(objectRoot);
Il risultato è visibile nella figura che segue:
TriangleStripArray e IndexedTriangleStripArray
Abbiamo accennato al fatto che la geometria del paragrafo precedente fosse la versione meno efficiente disponibile. Vale non solo per Java3D, ma per OpenGL in generale. Un TriangleArray usa tre tuple di tre valori float per ogni vertice, per ogni formato. Per una figura composta di mille triangoli, con vertici e normali, invieremmo attraverso il bus della scheda video circa 23kb di dati. Esistono strumenti largamente più efficienti. Considerate la figura seguente:
A sinistra usiamo un TriangleArray, a destra un TriangleStripArray. A differenza del primo, che definisce i triangoli come facce separate tra loro, ancorché adiacenti, un TriangleStripArray crea letteralmente una striscia di triangoli. Lo fa usando questo sistema:
i primi tre vertici definiscono un triangolo; dal quarto vertice in poi, un triangolo è definito usando il vertice seguente ed i due vertici del triangolo precedente. Ogni triangolo successivo al primo richiede pertanto la definizione di un solo vertice.
Esiste una versione che, a determinate condizioni, risulta ancora più efficiente. È IndexedTriangleStripArray. Il sistema è lo stesso di un TriangleStripArray. Tuttavia, anziché definire i punti che compongono una striscia, si usa una lista di tutti i punti che compongono la geometria della figura ed una lista di indici che, con riferimento a quella lista, determinano i tre punti del primo triangolo e, dal secondo in poi, il punto che, unito ai due immediatamente precedenti, definisce il triangolo successivo. Usando una lista di punti è possibile evitare di ripetere anche quei punti che siano vertici di più triangoli non in successione. Per massimizzare l'efficacia della definizione dei valori per le coordinate, possiamo usare degli array di float anziché oggetti Point3f o Vector3f. Per un GeometryArray è indifferente, esistono metodi setCoordinates, setNormals ed altri che accettano di buon grado anche semplici float. Nemmeno è troppo complicato per il programmatore. Sfruttando la possibilità di inizializzare un array all'atto della dichiarazione, la forma assunta dal codice somiglia molto alla definizione dei punti come entità autonome:
float[] vertices = {
0, 0, 0, //P0
1, 0, 0, //P1
1, 1, 0, //P2
0, 1, 0 //P3
};
I quattro normali hanno tutti lo stesso valore. Possiamo usare un array di lunghezza uno:
float[] normals = {
0, 0, 1 //V0 = V1 = V2 = V3
};
A questo punto servono le strisce. Non strisce di punti ma, come detto, strisce di indici:
int[] coordIndices = {
1, 2, 0, 3
};
Qui il punto di indice uno, unito al punto 2 ed al punto 0, formano il primo triangolo. Il secondo triangolo è definito dal punto 2, dal punto 0 e dal punto 3. Serve poi una striscia di indici anche per i normali. In questo caso i normali dei nostri vertici si riferiscono sempre allo stesso valore:
int[] normalIndices = {
0, 0, 0, 0
};
La definizione dei vertici di un oggetto potrebbe richiedere l'uso di più "strip". Ad esempio nel caso in cui la figura sia in qualche parte spezzata. I vertici della figura sarebbero comunque passati come un unico array di valori. La separazione delle parti sarebbe indicata attraverso l'uso di più strisce di indici. Per capire quante strisce siano effettivamente contenute nell'oggetto, un TriangleStripArray ed un IndexedTriangleStripArray richiedono che sia passata al costruttore una lista delle dimensioni di ciascuno "strip" di indici. Nel nostro caso abbiamo un solo "strip" di lunghezza 4. L'array richiesto ha pertanto la forma:
int[] strips = { 4 };
Il costruttore di IndexedTriangleStripArray richiede il numero di vertici, il loro formato, il numero di indici che formano la geometria e l'array di dimensioni delle strisce:
IndexedTriangleStripArray geom = new IndexedTriangleStripArray(
4, //Numero di coordinate
GeometryArray.COORDINATES | GeometryArray.NORMALS, //formato
4, //Numero di indici
strips //array di dimensioni, una dimensione per ogni strip
);
A questo punto è possibile usare i setter di IndexedGeometryArray (sottoclasse di GeometryArray) per immagazzinare i dati. A differenza di una geometria non indicizzata, dobbiamo passare valori e indici:
geom.setCoordinateIndices(0, coordIndices); geom.setCoordinates(0, vertices); geom.setNormalIndices(0, normalIndices); geom.setNormals(0, normals);
Per riassumere, creaimo una classe IndexGeometryGroup:
package it.j3dapp;
import javax.vecmath.*;
import javax.media.j3d.*;
public class IndexGeometryGroup extends BranchGroup {
public IndexGeometryGroup() {
float[] vertices = {
0, 0, 0, //P0
1, 0, 0, //P1
1, 1, 0, //P2
0, 1, 0 //P3
};
float[] normals = {
0, 0, 1
};
int[] coordIndices = {
1, 2, 0, 3
};
int[] normalIndices = {
0, 0, 0, 0
};
int[] strips = { 4 }; //la prima e unica striscia ha quattro indici
IndexedTriangleStripArray geom = new IndexedTriangleStripArray(
4, //Numero di coordinate
GeometryArray.COORDINATES | GeometryArray.NORMALS, //formato
4, //Numero di indici
strips //array di dimensioni, una dimensione per ogni strip
);
geom.setCoordinateIndices(0, coordIndices);
geom.setCoordinates(0, vertices);
geom.setNormalIndices(0, normalIndices);
geom.setNormals(0, normals);
Material mat = new Material();
mat.setEmissiveColor(new Color3f(1, 0, 0));
Appearance app = new Appearance();
app.setMaterial(mat);
Shape3D shape = new Shape3D(geom, app);
addChild(shape);
}
}
Il risultato visivo è identico a quello dell'array di triangoli. Dal punto di vista "meccanico" si è risparmiato molto al bus. La classe IndexedTriangleStripArray è, insieme a TriangleStripArray, la forma più efficiente disponibile per inviare dati riguardanti geometrie statiche. I due si contendono il primato. In effetti, nel caso in cui una figura sia composta di una o più strisce di triangoli, e questi non condividano dei punti, salvo i due che connettano un lato di un triangolo al successivo (senza rindondanza), allora la palma spetta a TriangleStripArray, perchè userebbe lo stesso numero di vertici di un IndexedTriangleStripArray ma non invierebbe, in più, gli array di interi degli indici. In genere tuttavia, il formato da preferire è IndexedTriangleStripArray, perché geometrie che abbiano un minimo di complessità (è sufficiente un quadrato composto di tante celle, ognuna costituita di due triangoli) hanno sempre un certo numero di vertici non successivi, condivisi da più triangoli. Parliamo di geometrie statiche, cioè di figure composte di vertici che, internamente, non mutano valore. A scanso d'equivoci, l'applicazione di una trasformazione attraverso un TransformGroup non cambia la geometria interna di un oggetto ma quella del sotto-spazio di coordinate a cui appartenga l'oggetto: un oggetto che cade, dunque, è sempre una geometria statica. È dinamica, invece, la geometria di quella figura le cui coordinate mutino internamente. È il caso, ad esempio, degli effetti di morphing (la deformazione del corpo di un oggetto) usati nelle animazioni, o nell'adaptive terrain rendering, dove la geometria che definisce un terreno adatta il proprio livello di dettaglio in base alla distanza di un settore dalla vista. Questo è il regno dei J3DBuffer.
J3DBuffer
Quando i dati della geometria di un oggetto siano destinati a cambiare nel tempo, è conveniente, in termini di prestazioni, che la geometria non sia copiata internamente ad un oggetto Geometry ma che questo mantenga semplicemente un riferimento ad una zona di memoria esterna, contenente i valori effettivi dei vertici. Questa zona di memoria può essere un semplice array di valori float. Esiste però un mezzo più efficiente, introdotto con la versione 1.3 di Java3D. La classe J3DBuffer è un wrapper (contenitore) per un java.nio.ByteBuffer (Java 1.4). Per essere precisi, è un wrapper per un ByteBuffer diretto in ordine nativo. Un Buffer, tipo a cui appartiene ByteBuffer, è definito come una sequenza lineare continua di valori. In pratica, è un blocco unico di celle di memoria contigue. In Java un ByteBuffer può essere inizializzato in due modi: "normale" o "diretto". A Java3D interessano solo i diretti. La differenza tra un Buffer normale ed un diretto non è nella velocità: più lento il primo, più rapido il secondo. È, invece, un gioco di posizione. Per sintetizzare, un Buffer normale esiste ad un livello più vicino alla piattaforma Java, un Buffer diretto esiste ad un livello più vicino all'hardware.
L'adozione dell'uno o dell'altro tipo di Buffer, dipende dalle necessità. Qualora lo si usi come contenitore di dati destinati ad essere scambiati tra parti di un'applicazione Java, lo strumento più rapido è il Buffer normale.
Quando invece la comunicazione sia tra un'applicazione Java una parte nativa, più vicina alla macchina reale, allora diventa più efficace che il buffer risieda ad un livello più basso e che la macchina virtuale controlli i dati "dall'alto". È il caso, ad esempio, del trasferimento di dati attraverso socket o destinati al disco fisso:
L'invio di dati al bus della scheda video è un caso emblematico di comunicazione tra il livello di Java ed il livello della macchina reale. E questo spiega perché un J3DBuffer richieda un Buffer di byte allocato in modo diretto.
Torniamo alla nostra figura in 3D. Per poter usare il sistema di riferimento ai dati di un J3DBuffer, un oggetto Geometry deve essere opportunamente istruito, sempre attraverso le bandiere che specificano il formato dei vertici. Volendo creare un IndexedTriangleStripArray con questo sistema il formato dei vertici sarebbe:
int vertexFormat =
GeometryArray.NORMALS | GeometryArray.COORDINATES |
GeometryArray.INTERLEAVED | GeometryArray.BY_REFERENCE |
GeometryArray.USE_NIO_BUFFER;
Di nuovo c'è la bandiera GeometryArray.BYREFERENCE, che informa l'oggetto circa l'uso del sistema del riferimento ad una zona di memoria, GeometryArray.USENIO_BUFFER, che segnale il fatto che non useremo un semplice array ma un J3DBuffer, e GeometryArray.INTERLEAVED. Quest'ultimo informa il GeometryArray di un particolare formato che useremo per comunicare i dati. Ancora una volta, si tratta di spremere dal sistema tutto ciò che questo è in grado di offrire. Passare i dati sui vertici in forma interposta (INTERLEAVED) significa che anziché usare due buffer, uno per le coordinate ed uno per i normali, useremo un buffer unico, in cui i dati sono, appunto, interposti. L'ordine di interposizione è prestabilito e deve essere seguito con rigore. Esso prevede che il buffer contenga:
coordinate texture punto 0, colore punto 0, normale punto 0, coordinate vertice punto 0, coordinate texture punto 1, colore punto1 , normale, punto 1 coordinate vertice punto 1, coordinate texture punto 2, colore punto2 , normale, punto 2 coordinate vertice punto 2, ... e così via fino all'ultimo punto
Si noti che non è necessario specificare texture, colori e normali. Dipende dal formato che abbiamo scelto per la geometria. Nel nostro caso abbiamo solo normali e coordinate dei vertici. Seguendo l'ordine fissato creeremo un buffer che contenga un normale, un vertice, un normale, un vertice e così via. Iniziamo definendo un array di normali e coordinate per il nostro quadrato:
float[] interleavedData = {
0, 0, 1, //normal
0, 0, 0, //vertex
0, 0, 1, //normal
1, 0, 0, //vertex
0, 0, 1, //normal
1, 1, 0, //vertex
0, 0, 1, //normal
0, 1, 0 //vertex
};
Per ogni vertice dobbiamo definire un normale. Il che è sensato: ricordate che usiamo una tecnica destinata alle geometrie dinamiche. Cambiando i punti, dovremo cambiare anche i normali, in modo diverso per ogni punto, affinché la reazione dell'oggetto all'illuminazione resti omogenea nonostante la trasformazione. Poiché costruiamo un IndexedTriangleStripArray, definiamo anche l'array di indici e l'array delle dimensioni delle strisce:
int[] indices = {
1, 2, 0, 3
};
int[] strips = { 4 };
Questa volta usiamo gli indici dei vertici anche per i normali, per via dell'interposizione. Ora passiamo al Buffer nio per il J3DBuffer. In un J3DBuffer non usa direttamente un ByteBuffer ma l'immagine FloatBuffer di un ByteBuffer diretto in ordine nativo. La costruzione di immagini è, fortunatamente, un'operazione semplicissima. La prima operazione consiste nella creazione di un ByteBuffer nio, con allocazione diretta. Nel Buffer dovremo inserire tanti byte quanti ne occorrano a contenere tutti i float dell'array di vertici interposti. Poiché un float Java occupa 4 byte, la dimensione del Buffer è 4 * interleavedData.length byte (96byte).
ByteBuffer buff = ByteBuffer.allocateDirect(4 * interleavedData.length);
Poi imponiamo al buffer di ordinare i byte secondo le specifiche della piattaforma. L'ordine può essere BIG o LITTLE ENDIAN, dipende dal sistema. L'ordinamento secondo le specifiche del sistema consente una trasmissione diretta, senza necessità di conversioni runtime. Trattandosi di Java, esiste il modo per interrogare al volo la piattaforma e sapere quale sia l'ordine da usare:
buff.order(ByteOrder.nativeOrder());
Stabilito l'ordine, otteniamo una vista FloatBuffer:
FloatBuffer interleavedBuffer = buff.asFloatBuffer();
Qui interleavedBuffer non è un secondo Buffer, copia del ByteBuffer ma in formato Float. È, invece, un mezzo per osservare i byte del ByteBuffer come fossero float. Creato il FloatBuffer, procediamo caricandolo dei dati contenuti nell'array:
interleavedBuffer.put(interleavedData, 0, interleavedData.length);
Il primo argomento è l'array di float, il secondo è l'indice del primo valore nell'array da cui partire per caricare il buffer, il terzo è il numero di valori da caricare. Noi immettiamo nel buffer tutti i dati a partire dal primo. Costruiamo la geometria:
IndexedTriangleStripArray tStrip = new IndexedTriangleStripArray(
4,
GeometryArray.NORMALS | GeometryArray.COORDINATES |
GeometryArray.INTERLEAVED | GeometryArray.BY_REFERENCE |
GeometryArray.USE_NIO_BUFFER,
4,
new int[] {4});
ed assegnamo il riferimento al buffer J3DBuffer. Esiste un metodo espressamente dedicato ai buffer di dati interposti:
tStrip.setInterleavedVertexBuffer(new J3DBuffer(interleavedBuffer));
Trattandosi di un IndexedGeometryArray, occorre passare le strisce di indici, per i normali e per le coordinate:
tStrip.setNormalIndices(0, indices); tStrip.setCoordinateIndices(0, indices);
Al solito, riassumiamo per chiarezza:
package it.j3dapp;
import javax.vecmath.*;
import javax.media.j3d.*;
import java.nio.*;
public class GeometryGroup extends BranchGroup {
public GeometryGroup() {
float[] interleavedData = {
0, 0, 1, //normal
0, 0, 0, //vertex
0, 0, 1, //normal
1, 0, 0, //vertex
0, 0, 1, //normal
1, 1, 0, //vertex
0, 0, 1, //normal
0, 1, 0 //vertex
};
int[] indices = {
1, 2, 0, 3
};
int[] strips = { 4 };
ByteBuffer buff = ByteBuffer.allocateDirect(4 * interleavedData.length);
buff.order(ByteOrder.nativeOrder());
FloatBuffer interleavedBuffer = buff.asFloatBuffer();
interleavedBuffer.put(interleavedData, 0, interleavedData.length);
IndexedTriangleStripArray tStrip = new IndexedTriangleStripArray(
4,
GeometryArray.NORMALS | GeometryArray.COORDINATES |
GeometryArray.INTERLEAVED | GeometryArray.BY_REFERENCE |
GeometryArray.USE_NIO_BUFFER,
4,
new int[] {4});
tStrip.setInterleavedVertexBuffer(new J3DBuffer(interleavedBuffer));
tStrip.setNormalIndices(0, indices);
tStrip.setCoordinateIndices(0, indices);
Material material = new Material();
material.setEmissiveColor(new Color3f(1f, 1f, 1f));
Appearance appearance = new Appearance();
appearance.setMaterial(material);
Shape3D shape = new Shape3D(tStrip, appearance);
addChild(shape);
}
}
Una parentesi per "tirare il fiato". Questa volta il quadrato è bianco poiché emissiveColor è (1, 1, 1). Un Color3f usa tre float per il canale rosso, verde e blu (RGB). I valori ammessi per ogni canale vanno da 0 a 1, dove 0 indica assenza di colore e 1 colore pieno. Se tutti e tre i canali valgano zero, allora il colore sarà nero, se valgano 1 allora sarà bianco. Ritroviamo il nostro quadrato, in bianco:
Anche nel caso di geometrie per riferimento si ripropone la sfida IndexedTriangleStripArray vs. TriangleStripArray. Questa volta, però, la questione si gioca sul piano delle capacità, più che dell'efficienza. Usando un IndexedTriangleArray è possibile modificare i valori associati ai vertici ma non la composizione delle strisce. In altri termini, la figura è deformabile ma non possiamo aumentare o diminuire i suoi vertici nè modificare la composizione delle strisce. In una geometria indicizzata, la composizione topografica della superficie è determinata dalle strisce di indici. In una geometria "BY_REFERENCE" le modifiche dei dati della geometria si effettuano modificando i valori contenuti nel buffer. Non essendo possibile passare per riferimento ad un buffer le strisce di indici, risulta impossibile modificare la composizione topografica della figura tridimensionale. Il numero dei vertici, e l'ordine in cui tali vertici compongano la figura, è immutabile. Un TriangleStripArray non ha questo limite, poiché il buffer dei vertici contiene sia le informazioni sulla geometria sia le informazioni sulla topografia della superficie. Potendo modificare il buffer è possibile manipolare ogni aspetto della figura tridimensionale. Per via di questa differenza, creiamo un'altra classe che dimostri l'uso di un TriangleStripArray al posto di un IndexedTriangleStripArray. A parte l'assenza delle strisce di indici, la diversità maggiore sta nell'ordine dei primi tre vertici della striscia. Ricordate come la striscia di indici del IndexedTriangleStripArray non fosse { 0, 1, 2, 3} ma { 1, 2, 0, 3 }, dovendo l'ultimo valore formare un triangolo con gli indici dei due punti precedenti. Non avendo più gli indici, dobbiamo riprodurre l'ordine dei quattro punti direttamente nel buffer interposto. A parte questo, la classe è anche più breve:
package it.j3dapp;
import javax.vecmath.*;
import javax.media.j3d.*;
import java.nio.*;
public class StripGeometryGroup extends BranchGroup {
public StripGeometryGroup() {
float[] interleavedData = {
0, 0, 1, //normal
1, 0, 0, //vertex
0, 0, 1, //normal
1, 1, 0, //vertex
0, 0, 1, //normal
0, 0, 0, //vertex
0, 0, 1, //normal
0, 1, 0 //vertex
};
int[] strips = { 4 };
FloatBuffer interleavedBuffer =
ByteBuffer.allocateDirect(4 * interleavedData.length).
order(ByteOrder.nativeOrder()).asFloatBuffer();
interleavedBuffer.put(interleavedData, 0, interleavedData.length);
TriangleStripArray tStrip = new TriangleStripArray(
4,
GeometryArray.NORMALS | GeometryArray.COORDINATES |
GeometryArray.INTERLEAVED | GeometryArray.BY_REFERENCE |
GeometryArray.USE_NIO_BUFFER,
new int[] {4});
tStrip.setInterleavedVertexBuffer(new J3DBuffer(interleavedBuffer));
Material material = new Material();
material.setEmissiveColor(new Color3f(1f, 0f, 1f));
Appearance appearance = new Appearance();
appearance.setMaterial(material);
Shape3D shape = new Shape3D(tStrip, appearance);
addChild(shape);
}
}
Ormai il quadrato è un amico di famiglia:
Questo chiude il primo articolo della serie Java3D.
Conclusioni
Abbiamo esaminato e visto all'opera alcune delle caratteristiche meno amichevoli di Java3D per quanto riguarda la gestione delle geometrie. Esiste a dire il vero un lato più amichevole del framework ma per essere competitivi nell'area delle applicazioni 3D dinamiche occorre, spesso, non scendere a compromessi. Scavando abbastanza in profondità , si scopre che Java3D consente una programmazione aggressiva ed efficace mantenendo, nonostante la complessità dei sottintesi, una certa semplicità d'uso.
Bibliografia
- [1] Sun Microsystem - "Getting Started with the Java3D API", http://java.sun.com/products/java-media/3D/collateral/index.html#tutorial
- [2] Sanchez-Crespo Dalmau - "Core Techniques and Algorithms in Game Programming", New Riders, Indianapolis, 2003
- [3] Eric Lengyel - "Mathematics for 3D Game Programming & Computer Graphics", Charles River Media, Hingham, 2004
