Android in 3D grafika
Kako vstopiti v svet 3D grafike na mobilnih napravah, smo predstavili v članku pretekli mesec. Spoznali smo vzpostavljanje osnovnega okolja za prikaz 3D grafike na zaslonu, inicializacijo podsistema OpenGL in definicijo enostavnih 2D primitivov - črt, trikotnikov in večkotnikov. Sestavili smo tudi tristrano piramido in jo obarvali. Tokrat se bomo osredotočili na naprednejše obarvanje objektov s teksturami. Vsebina članka je skupaj s programsko kodo dosegljiva na android.monitor.si.
Po pregledu osnov 3D grafike na platformi Android si bomo v tem članku pogledali implementacijo nekaterih naprednejših konceptov, kot so teksture in prosojnost, predstavili pa bomo tudi, kako se lotimo implementacije nekoliko kompleksnejših aplikacij oz. igric, ki uporabljajo 3D grafiko. Namen članka je predstaviti nizkonivojski pristop za izvedbo naprednejših funkcionalnosti. Na tem mestu velja omeniti, da se večina današnjih razvijalcev 3D računalniških iger z razvojem na tej ravni sploh ne ukvarja, temveč se razvoja iger lotevajo na dosti višji stopnji, z uporabo katerega izmed igračkarskih pogonov (angl. game engine) ali vsaj višjenivojskega programskega ogrodja (angl. framework).
Višjenivojska orodja programerju močno olajšajo razvoj, saj se mu ni več treba ukvarjati z vnovično implementacijo osnovne funkcionalnosti, temveč ima implementacije slednjih že na razpolago v ogrodju oz. orodju. Tako lahko veliko več časa nameni samemu razvoju zamisli in dodajanju vsebine, po drugi strani pa so s tem razvijalci omejeni zgolj na funkcionalnosti uporabljenega ogrodja oz. orodja. Če bi želeli v svoji igri uporabiti stvari, ki v ogrodju oz. orodju niso na voljo, morajo te funkcionalnosti implementirati sami, če je to sploh mogoče. Ker je razvoj funkcionalnosti za ogrodja drag, se ponavadi na enem ogrodju implementira več iger. Zgled takih ogrodij sta Unreal in Crysis Engine, na njiju je bilo razvitih kup dobro znanih iger.
Teksture
Na splošno želimo imeti zelo natančne 3D objekte, a jih je težko modelirati z veliko drobne geometrije, ki jo obarvamo z barvami. Da ne bi po nepotrebnem dodajali drobne geometrije, ki je med drugim tudi računsko zahtevna, se v računalniški grafiki za upodabljanje podrobnosti na modelih uporabljajo teksture. Teksture so v resnici slike, na katerih je upodobljena podrobnost neke površine. Takšno sliko prilepimo na površino, ki ji želimo dodati podrobnosti. Tako lahko tla v nekem prostoru, kjer je položen parket, izvedemo preprosto kot ravno površino, nanjo pa prilepimo sliko parketa, kot je prikazano na sliki 1. Teksture se poleg izrisanih podrobnosti uporabljajo za navidezne transformacije teles, na primer škode, povzročene na avtomobilu med vožnjo.
Slika1: Ploskev v 3D prostoru, slika parketa in slika parketa, prilepljena na ploskev
Če želimo slike uporabiti kot teksture, moramo pri tem v programski kodi določiti, kako naj se neka slika prilepi na določeno površino. Na površino, naj bo trikotnik ali pravokotnik, ne lepimo posamezne teksture, temveč ponavadi zgolj dele večje teksture. Poleg geometrije, ki jo izrisujemo, moramo definirati tudi koordinate točk v prostoru teksture, ki se preslikajo na posamezno točko geometrije. Prostor slike je definiran, kot je prikazano na slikah 2a in 2b. Prikazujeta dogajanje pri lepljenju teksture glede na prostor, v katerem je predmet. Slika se namreč lahko v prostoru ponavlja v posamezni smeri ali z zrcaljenjem (slika 2a). V teksturnem koordinatnem prostoru se lahko ponavljajo tudi zgolj zadnje vrstice teksture, zadnji stolpci ali oboje, kar je prikazano na sliki 2b. Te lastnosti pridejo do izraza, kadar želimo, da se neka tekstura na večji površini večkrat ponovi, na primer v primeru tal.
Slika2a : Obnašanje teksture v koordinatnem sistemu tekstur - ponavljanje
Slika2b: Obnašanje teksture v koordinatnem sistemu tekstur - ponavljanje zadnje vrstice/stolpca
Kako določimo, kateri del teksture naj se prilepi na kateri del geometrije, definiramo v programu s teksturnimi koordinatami. Za vsako točko geometrije moramo tako povedati, katera točka iz teksturnega prostora ji ustreza. Za preprost štirikotnik, ki ga zgradimo s pomočjo trikotniškega traku, je vrstni red podajanja teksturnih koordinat prikazan na sliki 3.
Slika3: Na štirikotnik prilepljena tekstura stranice lesenega zaboja
Teksture velikokrat združujejo dele, ki se prilepijo na določen del geometrije. Zgled kompleksnejše uporabe tega postopka je prikazan na sliki 4, kjer je tekstura različnih delov celotnega modela prikazana zgolj kot del celotne teksture. S pravilno definirami teksturnimi koordinatami poskrbimo, da na ustrezne dele geometrije prilepimo zgolj ustrezen del celotne teksture.
Slika 4 (vir: sentinel245.deviantart.com/)
V prejšnjem članku smo za izris geometrije definirali tri medpomnilnike: medpomnilnik za hranjenje položajev oglišč, medpomnilnik za hranjenje barv in medpomnilnik za hranjenje indeksov vrstnega reda izrisa oglišč. Tokrat bomo za pravilno lepljenje tekstur na geometrijo potrebovali še medpomnilnik za hranjenje teksturnih koordinat. V spodnjem zgledu najprej definiramo medpomnilnik, v katerem bomo hranili vrednosti teksturnih koordinat, in polje float vrednosti, ki jih predstavljajo.
private FloatBuffer texBuffer;
float[] texCoords = {
0.0f, 0.0f, // 1. left-bottom
1.0f, 0.0f, // 2. right-bottom
1.0f, 1.0f, // 3. left-top
0.0f, 1.0f // 4. right-top
};
Podobno, kot smo storili za vozlišča, barve in indekse, moramo napolniti tudi medpomnilnik, namenjen hranjenju teksturnih koordinat; to je prikazano v spodnjem zgledu.
ByteBuffer tbb = ByteBuffer.allocateDirect(texCoords.length * 4);
tbb.order(ByteOrder.nativeOrder());
texBuffer = tbb.asFloatBuffer();
texBuffer.put(texCoords);
texBuffer.position(0);
Da bomo za teksture sploh uporabili slike, si oglejmo, kako v program preberemo sliko iz pomnilnika in jo pretvorimo v obliko, primerno za uporabo v aplikaciji OpenGL. Najprej bomo v programu definirali polje kazalcev na prostor v pomnilniku, kamor bomo shranili podatke tekstur.
// Polje za hranjenje ene same teksture
int[] textureIDs = new int[1];
Za uvoz slik, ki jih bomo uporabljali za teksture, lahko uporabimo kar funkcionalnosti, ki so že zajete v paket Android API. Hkrati nastavimo tudi parametre podsistema OpenGL (glTexParameterf), s katerimi povemo, kako naj OpenGL slike pomanjšuje (GL_TEXTURE_MIN_FILTER) oz. povečuje (GL_TEXTURE_MAX_FILTER). Več o samem delovanju filtriranja si lahko preberete v sami specifikaciji standarda OpenGL ES, ki je dostopna v spletu (www.khronos.org/opengles/). V nadaljevanju je prikazana metoda, ki uvozi želeno teksturo. Uvoženo teksturo lahko v nadaljevanju uporabimo pri izrisu geometrije.
public void loadTexture(GL10 gl, Context context) {
// Ustvarimo polje za novo teksturo
gl.glGenTextures(1, textureIDs, 0);
// teksturo povežemo
gl.glBindTexture(GL10.GL_TEXTURE_2D, textureIDs[0]);
// Nastavimo ustrezne filtre
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_NEAREST);
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR);
// Kreiramo vhodni tok za sliko
// v mapi "res\drawable\slika.png"
InputStream istream = context.getResources().openRawResource(R.drawable.slika);
Bitmap bitmap;
try {
// preberemo in dekodiramo vhod
bitmap = BitmapFactory.decodeStream(istream);
} finally {
try {
istream.close();
} catch(IOException e) { }
}
// Kreiramo teksturo iz prebrane slike
// za trenutno povezano teksturo
GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, bitmap, 0);
bitmap.recycle();
}
Za prikaz tekstur v aplikaciji je treba omogočiti podporo teksturam tudi v podsistemu OpenGL. V navadi je, da podporo teksturam omogočimo, preden začnemo izrisovati objekte, ki imajo teksture. To naredimo s spodnjim klicem.
glEnable(GL10.GL_TEXTURE_2D);
Poleg predstavljenih osnov, potrebnih za uporabo tekstur, ostaja pri uporabi tekstur še kar nekaj drugih možnosti. Zgolj omenimo, da so poleg dvodimenzionalnih tudi eno- in tridimenzionalne teksture. Enodimenzionalne lahko uporabimo za dodajanje preprostih vzorcev, kot so prelivi ali črtni vzorci, na geometrijo, takšne teksture pa lahko uporabimo tudi za implementacijo dodatnih učinkov, kot so mehke sence ali naprednejši pristopi za izris z uporabo senčilnikov (angl. shaders).
Prosojnost
Za implementacijo prosojnosti v aplikaciji OpenGL je treba podobno kot za uporabo tekstur ustrezno nastaviti lastnosti podsistema OpenGL. Podsistemu moramo tako povedati, da bi želeli nekaj izrisovati z uporabo prosojnosti, prav tako je treba ustrezno nastaviti tudi lastnosti geometrije, ki jo želimo izrisovati. Prosojnost dosežemo s postopkom zlivanja (angl. blending), ki definira, kako naj se izrisujejo določene stvari. S postopkom zlivanja lahko poleg prosojnosti dosežemo tudi drugačne učinke, kot so maskiranje, prelivanje tekstur ali filtriranje tekstur. V nadaljevanju bomo predstavili, kako lahko postopek zlivanja uporabimo za izvedbo prosojnosti predmetov.
Oglejmo si osnove zlivanja barv. OpenGL pri izrisu uporablja različne medpomnilnike. Največkrat sta uporabljena barvni in globinski medpomnilnik, ki ju potrebujemo za izvedbo zlivanja. Barvni medpomnilnik hrani barvo točk, ki se bodo ob koncu izrisa okvira prikazale na zaslonu, globinski medpomnilnik pa hrani globinsko vrednost posamezne slikovne točke. Na podlagi vrednosti v globinskem medpomnilniku se določena točka v barvnem medpomnilniku izriše ali pa ne. Da bomo v programu lahko uporabljali globinski medpomnilnik, ga moramo ob inicializaciji omogočiti. To smo storili že v programu prejšnjega meseca s sledečim klicem:
glEnable(GL_DEPTH_TEST);
Pred začetkom izrisa medpomnilnike pobrišemo, kar je prikazano v spodnjem zgledu, in s tem poskrbimo, da v njih ni vrednosti, ki smo jih tja zapisali ob morebitnem predhodnem izrisu.
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
Pri izrisu se moramo zavedati delovanja barvnega in globinskega medpomnilnika. V barvni medpomnilnik se nova vrednost zapiše le, če je nova točka po globini pred trenutno zapisano točko. Če je nova točka po globinskem indeksu bolj oddaljena, pa se nova vrednost v barvni medpomnilnik ne zapiše.
Pri zlivanju pride poznavanje rabe medpomnilnikov še posebej do izraza, saj se pri izračunu nove vrednosti v barvnem medpomnilniku z določeno utežjo upošteva trenutna vrednost, ki je v barvnem medpomnilniku. Doslej smo pri risanju z barvami barve predstavljali s tremi barvnimi komponentami (rdeča, zelena in modra), Za potrebe prosojnosti povejmo, da lahko barve definiramo s štirimi komponentami, pri čemer četrta vrednost predstavlja stopnjo prosojnosti oz. alfa vrednost barve. Zlivanje je definirano s preprosto matematično formulo, po kateri novo vrednost barvne točke izračunamo kot uteženo vsoto trenutne (SRC) vrednosti slikovne točke in ciljne (DST) vrednosti slikovne točke. Paziti je treba tudi, da pred izrisom onemogočimo globinski test in ignoriranje narobe obrnjenih ploskev (angl. culling). Vrednosti uteži so lahko različne in so navedene v specifikaciji standarda OpenGL. Spodaj je prikazan zgled definicije zlivanja za preprosto prosojnost elementov, kjer kot utež trenutni barvi v barvnem medpomnilniku uporabimo alfa vrednost trenutne barve, kot utež nove barve pa z vrednostjo 1 minus vrednost prosojnosti trenutne prosojnosti.
// izris geometrije ozadja
...
// Upoštevamo tudi narobe obrnjena lica
glDisable(GL_CULL_FACE);
// onemogočimo globinski test
glDisable(GL_DEPTH_TEST);
// omogočimo zlivanje
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
// izris prosojne geometrije
...
//popravimo nastavitve nazaj na osnovno stanje
glEnable(GL_CULL_FACE);
glEnable(GL_DEPTH_TEST);
glDisable(GL_BLEND);
Pri izrisu prosojne geometrije je najpomembneje, da poskrbimo za pravilni vrstni red izrisa geometrije. Tako mora biti vsa geometrija, ki je za prosojno geometrijo - torej tista, ki je bolj oddaljena od opazovalca - izrisana pred prosojno geometrijo. Le tako namreč zagotovimo, da se vrednosti v barvnem medpomnilniku pravilno upoštevajo. S tem naletimo na nov problem sortiranja elementov, za katerega pa v tem članku še ne bomo podali rešitve. Prosojnost seveda velja tudi za teksturirano geometrijo. Če je na geometrijo napeta prosojna tekstura, se bo tudi prosojnost teksture ustrezno upoštevala pri izrisu. Zgled izrisa prosojne geometrije je prikazan na sliki 5.
Slika5: Zgled prikazuje izris prosojne geometrije
Luči in materiali
Da ustvarimo bolj realističen videz 3D prostora, so nam v okolju OpenGL na razpolago luči in materiali. Med tem, ko so luči namenjene osvetlitvi prizora, z materiali definiramo, kako se na luč z določenimi lastnostmi odziva geometrija z določenim materialom. Na razpolago so nam različne "vrste" luči in različni materiali, s čimer lahko dosežemo številne načine upodobitve.
Med seboj ločimo tri osnovne izvore svetlobe - luči. Pri točkastem izvoru vsa svetloba izhaja iz ene same točke v prostoru in se razširja na vse strani v prostoru. Reflektorski izvor je izvor, kjer svetloba prav tako izvira iz ene same točke, a se razširja zgolj v obliki stožca, ki mu lahko določimo kot v vrhu. Zadnji je usmerjen izvor svetlobe, ki se obnaša, kot da so vsi žarki vira vzporedni, podobno kot lahko obravnavamo sončno svetlobo na Zemlji. Vsakemu izvoru svetlobe lahko določimo intenziteto posamezne barvne komponente za ambientno, razpršeno (difuzni, angl. diffuse) in odbito (angl. specular) svetlobo, kar ustreza Phongovemu modelu senčenja. Ambientni del svetlobe je tisti, ki je po prostoru najbolj razpršen in deluje na vse objekte enako. Difuzni del svetlobe je najmočneje zastopan in predstavlja del svetlobe, ki se od objektov v prostoru razpršeno odbija v določeni smeri, odvisno od materiala. Odbiti del svetlobe pa predstavlja tisti del, ki se od posameznega objekta popolno odbije, kot pri zrcalu. Kot smo omenili, je vsak del svetlobe sestavljen iz treh barvnih komponent: rdeče, zelene in modre, ter prosojnosti. Osnovni pristop definicije luči v okolju OpenGL podpira rabo do osem strojno pospešenih luči (LIGHT0 do LIGHT7). V nadaljevanju je predstavljen zgled definicije točkastega izvora luči v okolju OpenGL, ki uporablja.
// definicija lastnosti luči za posamezno vrsto svetlobe
// četrta komponenta predstavlja prosojnost
private float[] lightAmbient = {0.5f, 0.5f, 0.5f, 1.0f};
private float[] lightDiffuse = {1.0f, 1.0f, 1.0f, 1.0f};
private float[] lightPosition = {0.0f, 0.0f, 2.0f, 1.0f};
...
// nastavitev lastnosti luči 1 v okolju
gl.glLightfv(GL_LIGHT1,GL_AMBIENT, lightAmbient, 0);
gl.glLightfv(GL_LIGHT1,GL_DIFFUSE, lightDiffuse, 0);
gl.glLightfv(GL_LIGHT1,GL_POSITION, lightPosition, 0);
// vklop luči 1
gl.glEnable(GL_LIGHT1);
Treba je še poskrbeti za uporabo luči pri izrisu, to izvedemo s spodnjim klicem.
glEnable(GL_LIGHTING);
Enako, kot je svetloba izvorov luči sestavljena iz različnih delov svetlobe, se tudi materiali različno odzivajo na posamezen del svetlobe. Glede na odziv materiala na posamezen del svetlobe, lahko definiramo različne vrste materialov. Odziv na ambientni del svetlobe daje osnovno barvo objektu, ki je enakomerna po celotnem objektu (glej sliko 6). Razpršeni del svetlobe daje občutek osvetlitve objekta iz določenega vira. Tako dobimo občutek, da je predmet na eni strani svetlejši, na drugi pa temnejši (slika 6). Odziv na odbito svetlobo je viden kot svetli odsevi, večinoma bele barve, ki nam dajejo občutek, da je material svetleč.
Slika 6: Prikaz odziva materiala na različne vrste svetlobe: ambientna, difuzna in odbita. Na zadnji sliki je prikazan združen odziv materiala glede na model Phongovega senčenja. (vir: Wikipedia)
Podobno kot lahko definiramo lastnosti luči, lahko definiramo tudi lastnosti materiala. V prejšnjem članku smo pokazali, kako pri izrisu uporabljamo barve. Ko v okolju omogočimo luči, se barve ignorirajo. Geometriji določimo barvo s tem, da določimo njen material. Materialu lahko nastavimo odzive na vse tri vrste svetlobe: ambientno, razpršeno in odbito. Poleg tega lahko materialu nastavimo tudi lastnost izsevanja (angl. emission), s čimer material daje občutek izvora oziroma oddajanja svetlobe. Material je treba definirati pred izrisom določene geometrije. Vsa geometrija, ki se izriše po tej definiciji materiala, bo imela enak material. Če želimo, da ima del geometrije drugačen material, moramo poskrbeti, da pred izrisom material spremenimo. Primer definicije materiala je prikazan v spodnjem zgledu.
//definicija odzivov materiala, ki jo nastavimo
// ob inicializaciji geometrije
// material bo rdeče barve
float[] materialAmbient = new float[] {0.2f, 0.0f, 0.0f, 1.0f};
float[] materialDiffuse = new float[] {1.0f, 0.0f, 0.0f, 1.0f};
float[] materialSpecular = new float[] {1.0f, 1.0f, 1.0f, 1.0f};
float[] materialEmission = new float[] {0.2f, 0.2f, 0.2f, 1.0f};
...
// definicija materialov
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL11.GL_AMBIENT, materialAmbient, 0);
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL11.GL_DIFFUSE, materialDiffuse, 0);
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL11.GL_SPECULAR, materialSpecular, 0);
gl.glMaterialfv(GL10.GL_FRONT_AND_BACK, GL11.GL_EMISSION, materialEmission, 0);
// izris geometrije
...
Na tem mestu omenimo, da z uporabo luči v prostor nismo vpeljali senc. Osvetlitev objektov v 3D prostoru se preračunava neodvisno za posamezen objekt. Če želimo v prostor dodati tudi sence, moramo pri tem upoštevati razporeditev objektov v prostoru in to, kateri objekti mečejo sence na druge objekte za vsak izvor svetlobe v prostoru. To ni preprosta naloga, čaka nas v prihodnjih delih vodnika.
V pričujočem članku smo predstavili, kako lahko na 3D geometrijo dodamo podrobnosti z uporabo tekstur, kako naredimo objekte realnejše z uporabo luči in materialov. Z uporabo predstavljenih konceptov lahko naredimo 3D prizor veliko bolj realističen ali pa ga prilagodimo svojim zamislim o želenem videzu. V prihajajočih člankih bomo predstavili, kaj so senčilniki (angl. shaders), kako jih uporabimo in kakšne učinke lahko dosežemo z njimi. Prav tako bomo podali nekaj nasvetov o tem, kako se lotiti izrisovanja večjega prizora z veliko predmeti. Kot smo že omenili, pri razvoju iger velikokrat uporabljamo tudi različna orodja ali ogrodja, o čemer bomo prav tako nekaj več povedali v prihajajočih člankih.