A láthatatlan zongorista

Az adventi naptárban a hangokhoz és a háromdimenziós grafikához is szerepelt egy-egy trilógia, amelyek e témakörök alapjait, igyekeztek bemutatni. Rakjuk össze a kettőt: írjunk egy programot, amely zenél, és rajzol is! Keltsük életre a láthatatlan zongoristát, aki eljátssza nekünk Mozart Török indulóját, vagy bármit, amit szeretnénk! (Meg aminek a kottáját a neten megtaláljuk.)

Ehhez két új dologra lesz szükségünk:

A többi adott. A szintetizátor megvan múltkorról. A kettőt szinkronizálni már majdnem gyerekjáték: amikor egy hang meg van szólaltatva, akkor a zongora billentyűjét elmozdulva kell kirajzolni.

1 A MIDI fájlok

A MIDI a Musical Instrument Digital Interface rövidítése. Ez egy szintetizátorok és egyéb digitális hangszerek által használt szabvány: arra való, hogy azok egymással és számítógépekkel kommunikálhassanak. A MIDI buszon az eszközök különféle üzeneteket küldenek egymásnak, úgymint hangszer kiválasztása, hang megszólaltatása, tempó beállítása stb. Egy fájlformátum is része ennek: a .mid fájlokban ezen események vannak rögzítve, a keletkezési idejük mellett. Bár gondolhatunk úgy is a MIDI fájlokra, mintha maga a kotta lenne eltárolva bennük, de ez nincs így. Szerencsére a fájlokban ennél számunkra sokkal alkalmasabb módon van eltárolva a zene: konkrétan az szerepel mindegyik hangra, hogy melyik időpontban szólal meg, és melyik időpillanatban hallgat el. Ezeket az eseményeket sorrendben „eljátszva” a fájlban tárolt zenét rekonstruálhatjuk, a szintetizátorunk tudásának megfelelő minőségben.

A .mid egy bináris fájlformátum, amelyben ezek az adatok néhány bájtos darabokban, chunk-okban tárolódnak. Hogy ennek a részleteibe ne nagyon kelljen belemenni, a midicsv programot hívhatjuk segítségül, amely szövegfájllá alakítja ezeket. A Boci, boci tarka első pár hangja, amely MIDI formátumban így fest:

00000000 4D 54 68 64 │ 00 00 00 06 │ 00 01 00 01 │ 00 64 4D 54  MThd.........dMT
00000010 72 6B 00 00 │ 00 3E 00 FF │ 51 03 03 D0 │ 90 00 C0 00  rk...>. Q..А . .
00000020 00 90 3C 50 │ 32 80 3C 00 │ 32 90 40 50 │ 32 80 40 00  . <P2 <.2 @P2 @.
00000030 32 90 3C 50 │ 32 80 3C 00 │ 32 90 40 50 │ 32 80 40 00  2 <P2 <.2 @P2 @.
00000040 32 90 43 50 │ 64 80 43 00 │ 64 90 43 50 │ 64 80 43 00  2 CPd C.d CPd C.
00000050 64 FF 2F 00 │                                          d /.

A midicsv szöveges formátumában könnyebben olvasható számunkra is:

0, 0, Header, 1, 1, 100         fejléc, órajel
1, 0, Start_track
1, 0, Tempo, 250000             sebesség
1, 0, Program_c, 0, 0
1, 0, Note_on_c, 0, 60, 80
1, 50, Note_off_c, 0, 60, 0
1, 100, Note_on_c, 0, 64, 80    hang megszólaltatása
1, 150, Note_off_c, 0, 64, 0
1, 200, Note_on_c, 0, 60, 80    hang kikapcsolása
1, 250, Note_off_c, 0, 60, 0
1, 300, Note_on_c, 0, 64, 80
1, 350, Note_off_c, 0, 64, 0
1, 400, Note_on_c, 0, 67, 80
1, 500, Note_off_c, 0, 67, 0
1, 600, Note_on_c, 0, 67, 80
1, 700, Note_off_c, 0, 67, 0
1, 800, End_track
0, 0, End_of_file

Nézzük meg, itt mi mit jelent.

Tehát ezt a fájlt kell beolvasni, és egy láncolt listát építeni a számunkra fontos parancsokból:

typedef enum EsemenyTipus {
    hangbe, hangki, tempobeallit,
} EsemenyTipus;

typedef struct Esemeny {
    EsemenyTipus tipus;
    /* tempohoz */
    int tempo;
    /* hanghoz */
    int ido;
    int csatorna;
    int hangmagassag;
    int hangero;

    struct Esemeny *kov;
} Esemeny;

A beolvasás szinte triviális, egy kis scanf()-elgetés megoldja a dolgot. A program egyszeresen láncolt, elején strázsás listát épít. Ebbe a listába az eseményeket idő szerint rendezve szúrja be, mivel a fájlban ezek sávonként elválasztva szerepelnek. (Még jó, hogy előadáson szerepelt, hogyan kell rendezett listát építeni. :P) Lejátszás közben jól fog jönni a rendezett lista, mivel azon az idő múlásával együtt kell majd végighaladni.

2 A vezérlés és a lejátszás

A lejátszás egyszerű megvalósításához érdemes a szintetizátor kódját átírni, de csak éppen hogy: a hangokat itt nem tömbben, hanem listában kell tárolni. Egyszerre több hang is megszólalhat, viszont az éppen megszólaltatott hangok száma folyamatosan változik. A szintetizátor így a hangok strázsás, egyszeresen láncolt listáját kell feldolgozza minden időszelethez. Felharmonikusok mixelgetése helyett most egy egyszerű háromszöghullámot használ a program, amelyet az fmod() (fűrészfoghullám) függvényből állít elő, egy abszolútérték képzésével:

for (iter = sz->hangok->kov; iter != NULL; iter = iter->kov) {
    Hang *h = iter;
    double s_ez;
    double frek = 264 * pow(2, (h->hangmagassag - 60) * 1.0/12.0);
    
    /* felharmonikusok helyett */
    s_ez = fabs(fmod(frek * h->t, 1)-0.5) * 4 - 1;
    ...

Egyébként minden a szokásos. Hang keltésekor a hangok listájába be kell szúrni egy új elemet, és indul az ADSR görbe a felfutással. A beszúrást a program hang_keres() függvénye csinálja, amely ha nem találja a listában a keresett, elvileg már előzőleg megszólaltatott hangot, akkor beteszi, és azzal tér vissza. Előbb-utóbb minden hang elér a csend fázisba – időnként a listát ki kell takarítani a hang_mar_nem_kell_torol() függvénnyel.

Ezeket a feladatokat a főprogram lejátszást vezérlő része végzi el. Ez létrehoz magának egy 20 milliszekundumonként aktiválódó időzítőt, amelynek minden jelére a MIDI parancsok listájából a következő adag parancsot végrehajtja. Ehhez ki kell számolnia azt is, hogy az eltelt 20 ms hány MIDI órajelnek felel meg. Ez a MIDI fájl Header és Tempo parancsaival megadott adatok alapján már könnyen megy.

int felbontas = 20;
id = SDL_AddTimer(felbontas, timer, NULL);
while (!kilep) {
    SDL_Event ev;
    SDL_WaitEvent(&ev);
    
    switch (ev.type) {
        case SDL_USEREVENT:
            SDL_LockAudio();    /* hogy addig ne fusson a masik szal */
            hang_mar_nem_kell_torol(h);
            midiorajel += (orajelpernegyed / (tempo/1000.0)) * felbontas;
            while (l != NULL && l->ido < midiorajel) {
                switch (l->tipus) {
                    case tempobeallit:
                        tempo = l->tempo;
                        break;
                    case hangbe:
                        ez = hang_keres(h, l->csatorna, l->hangmagassag);
                        ez->all = felfutas;
                        ez->hangero = l->hangero/128.0;
                        gombok[l->hangmagassag].nyomva++;   /* kirajzolonak */
                        break;
                    case ...

A zongorát kirajzoló programrész dolgát megkönnyítendő, ez eltárolja azt is, hogy épp melyik billentyű (a MIDI hang sorszámát indexnek használva) van lenyomva.

Figyelni kell arra, hogy a hangot keverő, SDL által visszahívott függvény egy külön szálon fut a főprogramtól. (Erről részletesen majd a Szoftlab 3. tárgyból lesz szó.) Mivel mindkettő a hangok listáján dolgozik, előfordulhat az, hogy amíg a hang előállítása közben épp végiglépked a listán a hang_callback() függvény, addig a fenti programrész módosítja a listát, például töröl belőle egy elemet. Az ilyesmivel szép kis lefagyásokat lehet csinálni, úgyhogy mindenképp el kell kerülni. A fenti programrészben a lista módosítása (vagyis a következő néhány MIDI parancs feldolgozása) előtt kiadott SDL_LockAudio() hívás azt jelzi az SDL-nek, ha épp meghívná a keverő hang_callback() függvényt, akkor még várjon vele egy kicsit. Ennek hatása az SDL_UnlockAudio() hívásig tart. Így nem lesz gond a lista kezelésével: egyszerre csak az egyik programrész fér hozzá a hangok listájához, megvárják egymást, hogy külön tudjanak dolgozni.

3 Az OpenGL könyvtárak

Nézzük meg jobb oldalt a rajzot: az egész pianínót el kell forgatni, a billentyűit mozgatni (forgatni) kell, amikor le vannak nyomva, perspektívát kell számolni és így tovább. Lehetne bíbelődni ebben a programban is a négyszögekkel, forgatással, sorbarendezéssel, de ezt most hagyjuk: bízzuk a kirajzolást egy megfelelő grafikus környezetre és a videókártyára, az OpenGL-en keresztül.

Az OpenGL-es 3D programozásnál először az a furcsa, hogy rengeteg koordinátarendszert használ: akármelyik ezzel foglalkozó könyvet nyitjuk ki, mindenféle objektumkoordinátákról, világkoordinátákról, normalizált eszközkoordinátákról, vágási koordinátákról, ablakkoordinátákról és egyebekről írnak benne. Ha azonban tudjuk, hogy melyik műveletet (a testek forgatását, a nézetet meghatározó kamera elhelyezését, a perspektívát) melyik koordinátarendszerben kell elvégezni, akkor könnyű a dolgunk. Az egyszerűbb programokban pedig arra sem lesz szükségünk, hogy magunk adjuk meg a 4×4-es mátrixokat, amelyek az egyes koordinátarendszerek közötti transzformációkat adják meg, hanem ezt a GL (OpenGL) vagy a GLU (OpenGL Utility) függvényei létrehozzák azokat helyettünk.

Az OpenGL ugyanis nem is egy, hanem több függvénykönyvtárból áll. A legalapvetőbb szolgáltatásokat a GL könyvtár biztosítja. Ennek kiegészítése a GLU, amely sok mindenben megkönnyíti a programozó dolgát: például ad egy olyan függvényt, amelynek segítségével megadhatjuk, hogy a jelenetünkben hol legyen a kamera (néző), és melyik pont felé nézzen – ahelyett, hogy nekünk kellene forgatásokkal és eltolásokkal a megadott pontba helyezni a megjelenített tárgyakat. A két könyvtár szolgáltatásait egy harmadikkal is ki szokták egészíteni: az OpenGL ugyanis csak rajzolni tud, ablakot nyitni, billentyűzetet, egeret kezelni nem. A harmadik könyvtár, amelyik ezeket a feladatokat ellátja majd, az most is a jól bevált SDL lesz. Na de lássuk ezeket a koordinátarendszereket!

4 Az OpenGL koordinátarendszerei

Nézzük meg, hogyan rajzolódik ki egy kocka a képernyőre!

Először is, a kocka ún. objektumkoordinátákkal rendelkezik. Ezeket az objektumkoordinátákat magához a kockához viszonyítjuk. Például azt mondhatjuk, hogy legyen a kocka egyik sarka az origó, és legyenek az x, y, z tengelyek ebben a rendszerben párhuzamosak a kocka éleivel. Ha a kocka éle 1 egység hosszú, akkor az előbb kiválasztott csúcsával szembeni csúcsának objektumkoordinátái a (1;1;1) számok.

Ezt a kockát nagyíthatjuk, kicsinyíthetjük, elforgathatjuk, és eltolhatjuk különböző helyekre a térben (modelltranszformáció), sok, különálló kockát kapva így. Magukhoz képest ezek mind egyformák, azonban az így kapott kockák egészen különböző világkoordinátákkal rendelkeznek. Bármelyik kocka koordinátarendszeréből nézve leírhatnánk az összes többiét, de az a rendszer, amit a programunkban használunk, lehet, hogy nem igazodik egyikéhez sem.

Van tehát egy jelenetünk a kockákból. Ebbe a jelenetbe belehelyezünk egy kamerát (vagy: magunkat) egy (x;y;z) pozícióba, és kiválasztunk egy másik pontot is, amelynek irányába nézünk. A világkoordinátákat a szem irányába tolva és forgatva (nézettranszformáció) megkapjuk a nézetkoordinátákat. Mivel az OpenGL-nek a világkoordinátákkal semmi dolga nincsen, ezért a modelltranszformációt (a kocka térbeli elhelyezését) és a nézettranszformációt (a kamera jelenetbe helyezését) egyetlen egy, összevont transzformációval oldja meg. Ez jogos is: mindegy, hogy a kocka jön hozzánk közelebb, vagy mi megyünk a kockához közelebb, a látvány ugyanúgy változik.

Ezután jön a további mágia: az általunk megadott térrészt az OpenGL a jelentből kivágja, elvégzi rajta a perspektivikus transzformációt, ami által megkapja az ún. normalizált eszközkoordinátákat, amelyekkel már egy két dimenzióba lapított képet ír le. Végül az így kapott képet méretezi bele a kért ablakunkba a képernyőn, vagyis számolja át az utóbbiakat ablakkoordinátákká. A legelőbb említett koordináták igazából nem is három, hanem négy dimenziósak, mert némely műveletek, amelyekre vágás és a perspektíva számítása közben szükség van, csak négy dimenzióban végezhetők el helyesen. Hát igen, a projektív geometria szabályai mások, mint az euklideszi geometria szabályai: a párhuzamosok igenis találkoznak egy pontban.

(A kockákat kirajzoló program kódja is letölthető lentebb.)

5 A koordinátarendszerek használata

Hogy fogjuk a fentieket használni a programban? Először is, a program indulásakor egyszer beállítjuk a vetítési transzformációt. Ezel adjuk meg azt, hogy milyen széles (hány fokos) a nézőnk látómezeje, és azt is, hogy milyen messze van a szemhez legközelebbi, illetve legtávolabbi pont, amelyet szeretnénk látni. Ehhez először szólunk az OpenGL-nek, hogy szeretnénk beállítani a vetítési mátrixot (1). Utána fogunk egy egységmátrixot (2), és ezt megszorozzuk egy olyan mátrixszal (3), amely a kívánt vetítést létrehozza. Az alábbi példában a látószög 35 fokos, a képarány w/h (igazodik az ablak méretéhez), a közeli és a távoli vágósík távolsága a szemtől 10 és 300 egység. (Ez utóbbiak megadására azért van szükség, mert az OpenGL z-pufferezést használ: minden képponthoz elmenti, hogy milyen távoli alakzat látszik ott, és így oldja meg az egymást átfedő alakzatok kirajzolását. Az egyes pontokhoz rendelt számérték felbontása azonban véges, és nekünk kell megmondani, mi a legkisebb és a legnagyobb érték.)

int w = 800, h = 600;
SDL_SetVideoMode(w, h, 0, SDL_OPENGL);
glMatrixMode(GL_PROJECTION); // 1
glLoadIdentity();                      // 2
gluPerspective(35.0, w / (double) h, 10, 300); // 3

A jelenetek kirajzolásakor pedig beállítjuk a modell- és a nézettranszformációt. Először is megadjuk, hogy hol van a szemünk: a (-15;30;60) világkoordinátából nézünk a (0;0;0) pontba, méghozzá úgy, hogy a felfelé irányt a (0;1;0) vektor adja meg:

glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(-15, 30, 60, 0, 0, 0, 0, 1, 0);

Ezután pedig minden objektum elhelyezése előtt úgy forgatjuk a világot (nem az objektumot!), hogy a világ álljon be az objektum által elvárt irányba. Ezt a lehetőséget a zongora billentyűinél fogjuk kihasználni. A billentyűk koordinátái a programban úgy vannak megadva, hogy a föld síkjával párhuzamosak, és az origóban vannak. A világ eltolása és elforgatása miatt lesz egy zongorabillentyű helyett sok, és amiatt lesznek lenyomva is. Ez így néz ki:

static void billentyurajzol(double xt, int fekete, double szog) {
    double w = (fekete ? 0.63 : 0.9) / 2;  /* fel szelesseg */
    double y = fekete ? 0.6:0;             /* ennyivel van magasabban */
    double z2 = (fekete ? 3.5 : 5);        /* hosszusag */
    double magas = 1;                      /* billentyu magassaga */
    double szin = fekete ? 0.2 : 0.9;

    glPushMatrix();
    glTranslatef(xt, 0, 0);         /* itt kerulnek a helyukre */
    glRotated(szog, 1, 0, 0);       /* ettol vannak lenyomodva, x tengely korul forgas */
    glColor3d(szin, szin, szin);    /* ilyen szinu */
    teglatest(w, y, 0, -w, y-magas, z2);
    glPopMatrix();
}

Ez a programrész kihasználja, hogy a transzformációs mátrixokat az OpenGL egy veremben tárolja. Az aktuálisan használt mátrixról készít egy másolatot, és beteszi azt a verembe (push). A módosítása, és a módosított mátrix használata után pedig előveszi az eredetit (pop). Ezt a programokban úgy szokás használni (és itt is így működik), hogy minden objektum elmenti a verembe a nézettranszformáció mátrixát, utána módosítja azt a saját helyzetnének meghatározásához. Végül a kirajzolás után visszaállítja az eredetit, hogy a többi objektum kirajzoló rutinja az eredetiből tudjon kiindulni.

6 Rajzolás az OpenGL-ben

„Az OpenGL állapotgép”, szokták mondani mindenhol. Ez láthattuk már az előző példában is: a glMatrixMode() függvénnyel előbb megmondjuk, hogy melyik mátrixot szeretnénk módosítani, az utána hívott függvények pedig azt a mátrixot (vetítési, modelltranszformációs) fogják változtatni, amelyet ezzel kiválasztottunk.

Ez az összes többi függvénynél is így van. A glVertex…() függvényekkel térbeli pontokat adhatunk át az OpenGL-nek. Hogy azokból háromszögek vagy négyszögek lesznek, esetleg csak sima szakaszok, az attól függ, hogy előtte a glBegin() függvényt milyen paraméterrel hívtuk meg. Ha azt mondjuk, glBegin(GL_TRIANGLES), háromszögeket kapunk. Ha pedig glBegin(GL_QUAD_STRIP), akkor egy összefüggő, négyszögekből álló csík (szalag) csúcsaiként értelmezi a megadott pontokat. Ugyanez a helyzet a színek megadásánál is: mindig az a szín van érvényben, amit a legutoljára megadtunk. Ha sok egyforma színű alakzatunk van, akkor ez jelentősen gyorsabb lesz ahhoz képest, mintha mindegyiknél egyesével megadnánk a színeket. (A glVertex3d() és a glColor3d nevében a 3d azt jelenti, hogy három darab double paramétere van. Sok OpenGL függvénynek több változata is van: pl. a glVertex2f ugyanazt csinálja, mint a glVertex3d(), csak két float paramétert vár; a harmadik koordinátát nullának veszi. A glColor3ub()-vel pedig az SDL-ben megszokott módon adhatjuk meg a színeket: glColor3ub(255, 255, 255) jelenti a fehéret.)

A program (x1;y1;z1) és (x2;y2;z2) koordinátákkal adott, tengelyekkel párhuzamos téglatest kirajzolását elvégző függvénye így néz ki:

static void teglatest(double x1, double y1, double z1, double x2, double y2, double z2) {
    ...
    glBegin(GL_QUADS);
    ...
    glNormal3d(-1, 0, 0);   /* bal */
    glVertex3d(x1, y1, z1);
    glVertex3d(x1, y1, z2);
    glVertex3d(x1, y2, z2);
    glVertex3d(x1, y2, z1);
    glNormal3d(1, 0, 0);   /* jobb */
    glVertex3d(x2, y2, z1);
    glVertex3d(x2, y2, z2);
    glVertex3d(x2, y1, z2);
    glVertex3d(x2, y1, z1);
    ...
    glEnd();
}

Ez a színt be sem állítja, hanem azt várja a hívótól, hogy az hívja meg helyette az OpenGL megfelelő glColor…() függvényét. Helyette rögtön négyszögrajzoló módba vált, és el is kezdi a hat lap koordinátáinak megadását. Egy lap négy csúcsból áll, tehát négy glVertex…() hívás tartozik hozzá. A hívások előtt megadjuk az OpenGL-nek a lap, mint felület normálvektorát is – ez egy olyan vektor, amelyik a felületre párhuzamos, és egységnyi hosszú. A téglalap bal oldali lapjának például (x=-1;y=0;z=0) a normálvektora, tehát balra mutat. Erre az OpenGL-nek a fények számítása miatt van szüksége.

A rajzolás menete pedig a régebben kirajzolt kép törléséből (glClear()), a testek kirajzolásából, és a megrajzolt kép képernyőre másolásából áll. Minden kirajzolás elején letöröljük az addigi rajzot, beállítjuk a nézetet; a végén pedig az SDL_Flip()-hez hasonlóan megjelenítjük az eredményt:

/* rajzolas kezdete */
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
gluLookAt(-15, 35, 70, 0, -5, 0, 0, 1, 0);

/* ... rajzolas helye ... */

/* rajzolas vege, mehet a kepernyore */
SDL_GL_SwapBuffers();

7 A forráskód

Hát ennyi. Nagyon kis rövid bevezető, szinte inkább csak kedvcsináló a komolyabb, hardveresen gyorsított 3D grafikához. Ez az írás teljesen nélkülözte az egész témakör matematikai oldalát – a harmadik év Számítógépes grafika és képfeldolgozás c. tárgyában minden részletesen szerepelni fog.

A színes kockákat kirajzoló program forráskódja: kocka.c. A zongorás kód előtt érdemes ezt tanulmányozni. Linuxon az SDL mellett GL és GLU könyvtárakat kell linkelni hozzá:

gcc *.c -o zongora `sdl-config --cflags --libs` -lm -lGL -lGLU

Windowson, Code::Blocksban pedig egy SDL-es projekthez a szokásos SDL_gfx helyett az opengl32 és a glu32 nevűt.

A zongora forráskódja és néhány példa fájl elérhető itt: zongora.zip. Aki kedvet kapott, megpróbálkozhat az adventi naptár 3D-s programjainak átírásával: a síelős és a drótvázakat kirajzoló program talán még egyszerűbbé is válik az átírással.