Rozdział 16.
Zaawansowane techniki graficzne - OpenGL i DirectX
William Woodbury
Wprowadzenie do standardu OpenGL
Wykorzystanie funkcji OpenGL
Wprowadzenie do standardu DirectX
Interfejs DirectDraw
Interfejs DirectSound
Pozostałe elementy standardu DirectX
Materiały uzupełniająceMigracja z systemu DOS do graficznych środowisk roboczych, jak np. Microsoft Windows, przyniosła programistom (w szczególności zaś twórcom gier komputerowych) nowe problemy, wśród których niepoślednie miejsce zajmował spadek wydajności programów. Przyczyną spowolnienia pracy aplikacji graficznych był narzut obliczeniowy związany z wielozadaniowością systemu oraz komplikacją funkcji, realizujących graficzny interfejs użytkownika. Remedium na tę bolączkę przyniosły dopiero multimedialne interfejsy programowe, jak OpenGL oraz DirectX firmy Microsoft, zapewniające znacznie większą wydajność operacji graficznych. Nikogo zaś chyba nie trzeba przekonywać, że wydajna obsługa grafiki jest w wielu aplikacjach absolutnie niezbędna. Na szczęście zagadnienie to nie jest tak skomplikowane, jak mogłoby się --> wydawać[Author:ts] . W niniejszym rozdziale omówimy jego podstawowe elementy.
OpenGL to „wysokopoziomowy” interfejs urządzeń graficznych, zapewniający emulację wszystkich funkcji sprzętowych (co umożliwia jego wykorzystanie również w przypadku braku implementacji sprzętowej). Nazwa DirectX oznacza rodzinę interfejsów programowych, realizujących dostęp do urządzeń graficznych i dźwiękowych w komputerach pracujących pod nadzorem systemu Microsoft Windows. W rozdziale tym przedstawimy wprowadzenie do obu systemów oraz opiszemy metody korzystania z funkcji OpenGL i wywołań interfejsów DirectDraw i DirectSound w programach tworzonych za pomocą C++Buildera 5.
Wprowadzenie do standardu OpenGL
Naszą dyskusję rozpoczniemy od przedstawienia standardu OpenGL i jego zastosowań. Nazwa OpenGL oznacza interfejs programowy, obsługujący tworzenie grafiki trójwymiarowej. Innymi słowy, OpenGL jest po prostu zestawem funkcji i struktur ułatwiających programowanie złożonych, trójwymiarowych operacji graficznych.
Warto podkreślić, że OpenGL jest de facto jedynie specyfikacją interfejsu - spośród licznych implementacji do najważniejszych należą rozwiązania opracowane przez firmy Silicon Graphics, Inc. (SGI) oraz Microsoft (MS). Sama specyfikacja została opracowana i jest aktualizowana przez niezależną, wyspecjalizowaną grupę roboczą o nazwie OpenGL Architecture Review Board (ARB).
Standard OpenGL jest w założeniach niezależny od platformy sprzętowej i programowej. Oznacza to możliwość swobodnego przenoszenia kodu pomiędzy różnymi systemami, o ile tylko ograniczymy się do wykorzystania zestawu funkcji zatwierdzonego przez ARB, a system docelowy zawiera implementację interfejsu OpenGL. Funkcje OpenGL realizowane są w miarę możliwości sprzętowo, co daje znaczącą poprawę szybkości, zwłaszcza że większość nowoczesnych komputerów wyposażona jest w karty graficzne zapewniające sprzętowe wspomaganie operacji 3D. Bardzo dobra optymalizacja procedur graficznych zapewnia zresztą dobrą wydajność, nawet w przypadku braku akceleracji sprzętowej.
OpenGL staje się powoli dominującym standardem tworzenia grafiki trójwymiarowej. Bazują na nim praktycznie wszystkie liczące się pakiety do modelowania przestrzennego i większość nowszych gier komputerowych. Doskonałe dopracowanie standardu przejawia się również w konstrukcji kodu źródłowego i nazewnictwie funkcji i struktur danych, wykorzystującym przejrzyste i łatwe do przyswojenia schematy.
OpenGL a Direct3D
Odpowiedzią Microsoftu na standard OpenGL jest Direct3D - interfejs programowy realizujący obsługę grafiki trójwymiarowej i umożliwiający bezpośrednią komunikację ze sprzętem. W odróżnieniu od swojego konkurenta, Direct3D jest rozwiązaniem nieprzenośnym i dostępnym w tylko jednej implementacji, opracowanej przez Microsoft.
Uwaga
Jednoczesne użycie w aplikacji wywołań OpenGL i Direct3D lub DirectDraw jest niedopuszczalne. Wynika to z faktu, iż niektóre sterowniki programowe OpenGL wykorzystują interfejsy Direct3D i DirectDraw do emulacji funkcji OpenGL w komputerach wyposażonych w karty graficzne niezgodne z tym ostatnim. Równoległe użycie w programie wywołań OpenGL i Direct3D lub DirectDraw może doprowadzić do konfliktu i spowodować błędy w działaniu aplikacji.
Struktura polecenia OpenGL
Tworzenie pierwotnych obiektów graficznych (tzw. prymitywów, ang. graphics primitive) na scenie graficznej odbywa się w standardzie OpenGL za pomocą poleceń o ujednoliconej strukturze. Większość poleceń OpenGL jest ponadto niezależna od rodzaju obiektu docelowego, co oznacza, że efekt ich użycia względem dowolnego obiektu będzie zawsze taki sam. Przykładem może tu być funkcja glColor3f(), której działanie (ustalenie koloru) jest identyczne dla każdego obiektu pierwotnego.
Rodzaj tworzonego obiektu określa się za pomocą parametrów wywołania funkcji glBegin(). Wraz ze swoim odpowiednikiem, glEnd(), tworzy ona swoiste „nawiasy”, obejmujące całość procedury rysowania obiektu.
Aktualizacja zawartości sceny w funkcji OnIdle()
Zawartość sceny graficznej rysowana jest na ogół w tzw. pętli aktualizującej. Pojęcie to oznacza cyklicznie wykonywany fragment programu, którego zadaniem jest tworzenie obiektów wchodzących w skład sceny i aktualizacja jej zmiennych elementów (np. przemieszczanie obiektów ruchomych), czyli animacja. Ciągłą aktualizację zawartości sceny można zrealizować na kilka sposobów, m.in.:
używając oddzielnego wątku (metoda ta, choć ogólnie dość dobra, wiąże się z pewnym narzutem związanym z obsługą pracy wielowątkowej);
używając funkcji OnIdle(), obsługującej stan jałowy programu (rozwiązanie preferowane).
OnIdle() jest funkcją wirtualną, wywoływaną cyklicznie w trakcie „jałowego biegu” programu, tj. w czasie, gdy nie przetwarza on żadnych innych komunikatów i nie wykonuje innych funkcji. Implementacja obsługi stanu jałowego sprowadza się do zdefiniowania własnej funkcji (nazwa IdleFunction jest oczywiście przykładowa i można ją zmienić) o następującej postaci:
void __fastcall TForm1::IdleFunction(TObject *Sender, bool &done)
{
// tu operacje rysowania
}
Pierwszy wiersz tak zdefiniowanej funkcji powinien zawierać instrukcję
done = false;
której działanie sprowadza się do zażądania od systemu przydzielenia dodatkowego czasu. Dalsza część kodu funkcji zawiera procedury rysowania zawartości sceny (zwykle rysowanie odbywa się w buforze drugoplanowym, którego zawartość jest następnie kopiowana na ekran). Aby tak zdefiniowaną funkcję wykorzystać w programie, należy użyć instrukcji
Application->OnIdle = IdleFunction;
Można to zrobić w dowolnym miejscu programu, jednak nie należy zmieniać właściwości OnIdle przed zainicjalizowaniem podsystemu OpenGL - w przeciwnym przypadku zawarte w funkcji OnIdle()wywołania OpenGL nie będą działały prawidłowo.
Wykorzystanie funkcji OpenGL
Większość programów wykorzystujących wywołania OpenGL ma podobną strukturę. Składające się na nią etapy przedstawiono poniżej.
Operacje wstępne i inicjalizacja podsystemu OpenGL. Etap ten obejmuje utworzenie tzw. kontekstu renderowania oraz kontekstu urządzenia.
Przygotowanie środowiska do utworzenia obiektów. Etap ten obejmuje ustalenie „warunków środowiskowych” do generacji obiektów, w tym zdefiniowanie parametrów oświetlenia, zamglenia i cieniowania.
Transformacja obiektów pierwotnych. Na tym etapie definiuje się współrzędne, orientację i współczynniki skali, określające położenie rysowanych obiektów. Szczegółowe omówienie tych operacji zawiera punkt „Etap 3 - przekształcenia 3D” w dalszej części rozdziału.
Generacja obiektów pierwotnych. Wynikiem tego etapu jest wygenerowanie poszczególnych punktów i zdefiniowanych przez nie obiektów, przy uwzględnieniu tekstur i właściwości materiałów.
Wymiana powierzchni. Etap ten dotyczy tylko operacji wykorzystujących podwójne buforowanie obrazu.
Etapy od 2. do 5. wykonywane są na ogół w ramach pętli aktualizującej (omówionej w punkcie „Aktualizacja zawartości sceny w funkcji OnIdle()”).
Etap 1 - inicjalizacja podsystemu OpenGL
Przed rozpoczęciem właściwych operacji graficznych niezbędne jest wykonanie pewnych czynności inicjalizacyjnych, których celem jest przygotowanie środowiska operacyjnego do mających nastąpić wywołań funkcji OpenGL. W ramach tego etapu tworzony jest tzw. kontekst renderowania (ang. rendering context), będący odpowiednikiem kontekstu urządzenia GDI. Funkcje OpenGL wykorzystują kontekst renderowania do przechowywania różnorodnych parametrów, jak np. zapis bieżącego stanu systemu graficznego.
Uwaga
Implementacje standardu OpenGL przeznaczone dla Windows zawierają dodatkowy zestaw funkcji przeznaczonych wyłącznie do komunikacji z systemem operacyjnym. Grupa ta jest potocznie określana mianem „wiggle” ze względu na fakt, iż nazwy tworzących ją funkcji poprzedzone są przedrostkiem wgl (od „Win32 GL”).
Do utworzenia kontekstu renderowania OpenGL na podstawie kontekstu urządzenia GDI służy funkcja wglCreateContext(). Sposób jej użycia przedstawiono na wydruku 16.1.
Wydruk 16.1. Inicjalizacja podsystemu OpenGL - funkcja OpenGLInit()
// zmienne globalne dla programu lub klasy
HDC hDC;
HGLRC hRC;
bool TForm1::OpenGLInit(void)
{
// Pobierz uchwyt kontekstu urządzenia dla danego okna (Form1)
hDC = GetDC(Handle);
// W razie błędu funkcja GetDC() zwraca wartość NULL.
if(hDC == NULL)
return(false);
// Tę funkcję omówimy nieco dalej.
SetGLPixelFormat(hDC);
// Utwórz kontekst renderowania na podstawie kontekstu urządzenia.
hRC = wglCreateContext(hDC);
// W razie błędu funkcja GetDC() zwraca wartość NULL.
if(hRC == NULL)
return(false);
return true;
}
Po utworzeniu kontekstów urządzenia i renderowania należy powiadomić o nich system operacyjny i podsystem OpenGL oraz uczynić je kontekstami bieżącymi. Służy do tego funkcja --> wglMakeCurrent[Author:ts] ():
wglMakeCurrent(hDC, hRC);
Warto zwrócić uwagę na znajdujące się na wydruku 16.1 wywołanie funkcji SetGLPixelFormat(). Operacja ta jest dość istotna, gdyż to właśnie tutaj ustalane są parametry kontekstu urządzenia związane bezpośrednio z funkcjonowaniem OpenGL. Innymi słowy, funkcja ta odpowiada za stworzenie warunków do prawidłowej wymiany danych pomiędzy funkcjami OpenGL a systemem operacyjnym. Do przekazania odpowiednich parametrów służy struktura typu PIXELFORMATDESCRIPTOR, zaś całą funkcję przedstawiono na wydruku 16.2.
Wydruk 16.2. Ustalenie formatu pikseli - funkcja SetGLPixelFormat()
void SetGLPixelFormat(HDC hdc)
{
int PixelFormatIndex;
PIXELFORMATDESCRIPTOR PixelFormat=
{
sizeof(PIXELFORMATDESCRIPTOR), // rozmiar struktury
1, // wersja struktury
PFD_DRAW_TO_WINDOW | // rysowanie bezpośrednio na ekranie
// (nie w buforze drugoplanowym)
PFD_SUPPORT_OPENGL | // zezwolenie na użycie funkcji OpenGL
// w kontekście urządzenia (przydatne)
PFD_DOUBLEBUFFER, // podwójne buforowanie
PFD_TYPE_RGBA, // tryb koloru RGBA
24, // kolor 24-bitowy
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // nieużywane
32, // 32-bitowy bufor głębi
0, 0, // nieużywane
PFD_MAIN_PLANE, // rysowanie na głównej powierzchni
0, 0, 0, 0 // nieużywane
};
// Wybierz indeks formatu pikseli zdefiniowanego powyżej.
PixelFormatIndex=ChoosePixelFormat(hdc, &PixelFormat);
// Wybierz format pikseli.
SetPixelFormat(hdc, PixelFormatIndex, &PixelFormat);
}
Tworzenie grafiki 3D w systemie OpenGL
Procesy związane z tworzeniem grafiki trójwymiarowej mogą okazać się dość skomplikowane. Jednym z zadań interfejsu OpenGL jest ich uproszczenie poprzez udostępnienie programiście stosunkowo łatwych w użyciu funkcji „opakowujących” złożone operacje tworzenia obiektów 3D.
Widok
Mianem widoku lub okna graficznego (ang. viewport) określany jest fragment ekranu (okno), w którym wyświetlane są obiekty tworzone za pomocą funkcji OpenGL. Mówiąc najprościej, widok to pewien wycinek płaszczyzny (powierzchni ekranu), na którym realizowana jest dwuwymiarowa projekcja obiektów umieszczonych w trójwymiarowej przestrzeni wirtualnej. Zawartość widoku odpowiada obrazowi przestrzennemu widzianemu z początku układu współrzędnych 3D (tj. punktu o współrzędnych [0, 0, 0]) wzdłuż osi Z.
Uwaga
Ze względu na brak miejsca nie będziemy tu zagłębiać się w szczegóły dotyczące reprezentowania obiektów trójwymiarowych na płaszczyźnie. Zainteresowanych Czytelników odsyłamy do podręczników stereometrii i książek z dziedziny trójwymiarowej grafiki komputerowej.
Tworząc grafikę trójwymiarową za pomocą funkcji OpenGL, należy przekształcić współrzędne lokalne na przetransformowane współrzędne globalne, a te z kolei - na współrzędne w układzie obserwatora. Pojęcie „przetransformowane współrzędne globalne” (ang. transformed world coordinates) oznacza współrzędne przekształcone za pomocą odpowiednich macierzy transformacji z lokalnego układu współrzędnych (tj. „prywatnego” układu współrzędnych tworzonego obiektu) na układ globalny (opisujący przestrzeń, w której zlokalizowane są wszystkie obiekty). Dodatkowe informacje na ten temat zawiera punkt „Etap 3 - przekształcenia 3D” w dalszej części rozdziału; wiadomości można też szukać w literaturze z dziedziny stereometrii i grafiki komputerowej 3D.
Pierwotne położenie punktów obiektu w przestrzeni trójwymiarowej dane jest w lokalnym układzie współrzędnych obiektu. Poddanie współrzędnych lokalnych odpowiednim przekształceniom (którymi zajmiemy się w dalszej części rozdziału) pozwala „przenieść” punkty definiujące obiekt do globalnego układu współrzędnych (ang. world coordinate system). Operację tę należy przeprowadzić tak, by zapewnić właściwe wzajemne rozmieszczenie obiektów.
W drugim etapie następuje kolejne przekształcenie współrzędnych w celu odpowiedniego rozmieszczenia obiektów względem obserwatora. Procedura ta sprowadza się do zastosowania do każdego obiektu transformacji odwrotnej w stosunku do tej, której używa się do ustalenia współrzędnych obserwatora względem poszczególnych obiektów, tworzących scenę. Jest to równoważne przemieszczeniu obserwatora do początku układu współrzędnych przy zachowaniu względnego rozmieszczenia poszczególnych obiektów. Tak przekształcona scena (czyli zespół obiektów i elementów zawartych w danym wycinku przestrzeni wirtualnej) jest następnie odwzorowywana na dwuwymiarowy widok przy zachowaniu względnego położenia poszczególnych elementów w stosunku do obserwatora. Orientację obserwatora względem globalnego układu współrzędnych przedstawiono na rysunku 16.1.
Rysunek 16.1. Położenie i orientacja obserwatora względem globalnego układu współrzędnych
Viewer coordinates and direction - Układ współrzędnych obserwatora i kierunek patrzenia
Cały ten wykład może wydawać się nieco skomplikowany, zwłaszcza dla nowicjuszy w dziedzinie grafiki 3D. Nie należy się jednak przerażać; przedstawione tu zagadnienia nabiorą sensu w miarę analizy praktycznych przykładów.
Skuteczne tworzenie scen grafiki trójwymiarowej opiera się w znacznej części na zdolności do „widzenia przestrzennego”, czyli wyobrażenia sobie układu poszczególnych obiektów i wyników ich przekształcania za pomocą określonych transformacji. Umiejętność ta bywa wrodzona, jednak można ją też w sobie rozwinąć; zresztą nawet jej brak nie powinien być powodem do poważniejszych zmartwień - wyniki działania programu można zawsze ocenić, oglądając je na ekranie.
Konfiguracja widoku
Konfiguracja widoku składa się z dwóch etapów. W pierwszym definiuje się właściwy widok („okno”, przez które obserwator widzi scenę 3D), zaś w drugim - ustala sposób prezentacji zawartości sceny na płaszczyźnie widoku. Przekształcenie trójwymiarowej sceny na płaską powierzchnię widoku określa się mianem rzutowania (ang. projection). Dwiema najczęściej stosowanymi metodami są rzutowanie równoległe (ang. orthographic projection) i rzutowanie perspektywiczne (ang. perspective projection).
W metodzie rzutowania perspektywicznego współrzędne wszystkich punktów przekształca się za pomocą odpowiedniej macierzy transformacji tak, by uzyskać złudzenie perspektywy (obiekty bardziej odległe od obserwatora wydają się mniejsze). Rzutowanie równoległe zachowuje rozmiary obiektów niezależnie od odległości od obserwatora, co daje w efekcie obrazy „płaskie” i pozbawione efektu głębi. Ten rodzaj rzutowania wykorzystywany jest głównie w programach architektonicznych i systemach komputerowego wspomagania projektowania (CAD), w których wymaga się na ogół „dosłownej” prezentacji obiektów.
Na wydruku 16.3 przedstawiono prostą funkcję, definiującą widok i ustalającą odpowiedni tryb rzutowania (perspektywiczne lub równoległe).
Wydruk 16.3. Konfiguracja widoku - funkcja SetViewport()
void TForm1::SetViewport(bool Perspective)
{
float w, h, Aspect;
// zmienne zewnętrzne
w=Width;
h=Height;
// dla uniknięcia dzielenia przez zero
if(h==0)
h=1;
// Ustaw widok na całą powierzchnię roboczą okna.
glViewport(0, 0, w, h);
// Wybierz macierz rzutowania
// (wszystkie operacje od tej chwili będą dotyczyły
// wyłącznie macierzy rzutowania).
glMatrixMode(GL_PROJECTION);
// Załaduj macierz jednostkową (niezmiennik transformacji).
glLoadIdentity();
// Jeśli zażądano rzutowania perspektywicznego...
if(Perspective)
{
// ustaw odpowiednie parametry.
Aspect=(GLfloat)w/(GLfloat)h;
gluPerspective(60.0f, Aspect, 1.0f, 1000.0f);
}
// W przeciwnym przypadku ustaw rzutowanie równoległe
else
{
if(w<=h)
glOrtho(-250.0f, 250.0f, -250.0f*h/w, 250.0f*h/w,
1.0f, 1000.0f);
else
glOrtho(-250.0f*w/h, 250.0f*w/h, -250.0f, 250.0f,
1.0f, 1000.0f);
}
// Przywróć macierz transformacji modelu.
glMatrixMode(GL_MODELVIEW);
glLoadIdentity();
}
Jak można zauważyć, w komentarzach kilkakrotnie występuje słowo „macierz”. Z naszego punktu widzenia macierz jest narzędziem matematycznym, umożliwiającym przekształcanie innych macierzy i wektorów (macierzy jednowymiarowych). Przekształcenia wykorzystywane w grafice 3D obejmują rzuty („macierz rzutowania” na wydruku) oraz tzw. transformacje afiniczne. W skład tej ostatniej klasy wchodzą operacje: przesunięcia, obrotu i jednokładności, którymi zajmiemy się w dalszej części rozdziału.
Zdefiniowanie widoku we współrzędnych klienta (okna ekranowego) realizuje funkcja glViewport(). Utworzony przez nią widok jest „oknem”, przez które obserwator widzi scenę 3D. Parametry funkcji glViewport() określają współrzędne ekranowe okna widoku:
glViewport(int x, int y, int w, int h)
Wartości x i y definiują położenie lewego dolnego wierzchołka okna widoku, zaś wartości w i h - szerokość i --> wysokość [Author:ts] okna.
Wywołanie funkcji glOrtho() nakazuje użycie rzutowania równoległego (definiuje zawartość macierzy rzutowania) i ustala współrzędne tzw. bryły --> obcinania[Author:ts] :
glOrtho(int left, int right, int bottom, int top,
float near, float far)
Parametry left i right określają współrzędne x lewej i prawej płaszczyzny obcinania („ściany” bryły obcinania); parametry bottom i top - współrzędne y dolnej i górnej płaszczyzny, zaś near i far - współrzędne z przedniej i tylnej płaszczyzny obcinania. Wnętrze tak utworzonego prostopadłościanu jest rzutowane równolegle na płaszczyznę widoku, przy czym wartości near i far określają zakres „głębokości” (odległości od obserwatora), obejmujący obiekty, które będą widoczne.
Użycie funkcji gluPerspective() pozwala skorzystać z rzutowania perspektywicznego. Oto sposób jej wywołania:
gluPerspective(float fov, float aspect, float near, float far )
Parametr fov określa tzw. kąt widzenia (ang. field of view), czyli rozwartość ostrosłupa ograniczającego widoczny wycinek sceny. Parametr aspect wyznacza proporcję szerokości przekroju ostrosłupa do jego wysokości, zaś parametry near i far mają znaczenie identyczne, jak w dla funkcji glOrtho().
Wskazówka
Przedrostek glu w nazwie funkcji gluPerspective() jest skrótem od „OpenGL Utilities” i oznacza, że funkcja należy do kategorii funkcji narzędziowych (pomocniczych).
Stany mechanizmu OpenGL
Generowaniem obiektów graficznych przez funkcje OpenGL steruje się poprzez określanie i modyfikację tzw. zmiennych stanu (ang. state variable). Nazwa ta określa wewnętrzną zmienną (obiekt) biblioteki OpenGL, reprezentującą określony parametr rysowania. Większość zmiennych stanu jest typu dwustanowego (logicznego), tj. może przyjmować wartości „prawda” lub „fałsz”, jednak niektóre, jak np. kolor i współrzędne źródła światła, są wartościami lub wektorami wartości typu zmiennoprzecinkowego.
Ustawianie i zerowanie zmiennych stanu
Do modyfikowania stanu zmiennej dwustanowej służą funkcje glEnable() i glDisable(). Na przykład w celu użycia tekstury dwuwymiarowej do generowania powierzchni należy użyć wywołania
glEnable(GL_TEXTURE_2D);
natomiast zabronienie użycia tekstury dwuwymiarowej uzyskuje się wywołaniem
glDisable(GL_TEXTURE_2D);
Odczyt zawartości zmiennych stanu
Ustalenie bieżącej zawartości zmiennej dwustanowej umożliwia funkcja glIsEnabled(), wywoływana w następujący sposób:
if(glIsEnabled(GL_TEXTURE_2D))
...
Etap 2 - ustalenie parametrów oświetlenia i cieniowania
Gdyby obiekty tworzące scenę były oświetlone w sposób jednolity, całość sprawiałaby wrażenie „płaskiej” i pozbawionej efektu głębi. Na szczęście specyfikacja OpenGL obejmuje także kilka prostych w użyciu funkcji, pozwalających zdefiniować parametry oświetlenia sceny i cieniowania obiektów. Zagadnienia te przedstawimy w dalszej części.
Modele oświetlenia i cieniowania
OpenGL umożliwia dynamiczną zmianę cieniowania w czasie rzeczywistym, co pozwala symulować zmiany oświetlenia obiektów wchodzących w skład sceny i w znacznym stopniu przyczynia się do zwiększenia realizmu generowanych obrazów. Kluczowym elementem dynamicznego cieniowania są wirtualne źródła światła, których odpowiednie rozmieszczenie pozwala uzyskać pożądane efekty.
Na wygląd oświetlonej płaszczyzny (powierzchni zdefiniowanej przez co najmniej trzy punkty w przestrzeni) składają się trzy rodzaje światła - światło otaczające (ang. ambient light), światło rozproszone (ang. diffuse light) oraz światło odbite (ang. specular light). Światło otaczające jest swego rodzaju „tłem”, normalnie obecnym w przestrzeni sceny i nie pochodzącym z żadnego konkretnego kierunku (wynika to z jego wielokrotnego odbicia i rozproszenia). Dwie pozostałe składowe oświetlenia, rozproszona i odbita, obejmują światło pochodzące z konkretnego źródła i odbite bezpośrednio od danej płaszczyzny. Światło rozproszone odbijane jest w wielu różnych kierunkach, co sprawia, iż jest ono widoczne pod każdym kątem w stosunku do powierzchni. Światło odbite odbijane jest dokładnie pod tym samym kątem, pod którym padło na powierzchnię (znane z optyki prawo Snelliusa). Ilustrację obu rodzajów światła przedstawiono na rysunku 16.2.
Rysunek 16.2. Odbicie i rozproszenie światła przez powierzchnię
Lighting diagram - [usunąć, powielenie tytułu rysunku]
Normal - normalna do powierzchni
Specular light - światło odbite
Diffuse light - światło rozproszone
Light source - źródło światła
Modele cieniowania w systemie OpenGL
OpenGL umożliwia użycie dwóch modeli cieniowania, zwanych potocznie płaskim (ang. flat) i gładkim (ang. smooth). W obydwu przypadkach kod rysujący jest zoptymalizowany pod kątem szybkości, co oznacza, że parametry oświetlenia wyliczane są tylko dla wybranych pikseli.
W modelu gładkim wartości koloru, wynikające z oświetlenia płaszczyzny, obliczane są dla każdego jej wierzchołka oddzielnie (np. trzy wartości dla trójkąta, cztery dla czworokąta itd.), a uzyskane w ten sposób wartości służą do określenia kolorów wszystkich pozostałych punktów płaszczyzny na drodze interpolacji. Metoda ta sprawdza się bardzo dobrze w zdecydowanej większości przypadków. W modelu płaskim poszczególne płaszczyzny traktowane są „całościowo” - tj. uzyskują jednolity kolor, obliczony jednorazowo dla wybranego punktu płaszczyzny. Metoda ta również daje niezłe efekty, o ile cieniowana płaszczyzna jest stosunkowo niewielka. Użycie modelu gładkiego jest nieco bardziej czasochłonne, jednak uzyskany efekt wizualny jest znacznie lepszy, zwłaszcza jeśli cieniowany obiekt składa się z niewielu płaszczyzn (w takiej sytuacji efekt użycia cieniowania płaskiego jest dość sztuczny).
Ustalanie parametrów oświetlenia
Do ustalenia parametrów źródła światła służą funkcje z rodziny glLight().
Wskazówka
Nazwy większości funkcji OpenGL tworzone są według konwencji nazewniczej, którą można symbolicznie zapisać jako
gl<NazwaFunkcji>[n][t](parametry)
Człon NazwaFunkcji opisuje zastosowanie funkcji (np. Vertex - definiowanie wierzchołków (ang. vertex), Light - definiowanie parametrów oświetlenia (ang. lighting), Normal - definiowanie wektorów normalnych do powierzchni (ang. normal vector). Wartość n określa liczbę parametrów funkcji, zaś t symbolizuje ich typ (np. f - wartość zmiennoprzecinkowa, float; d - wartość zmiennoprzecinkowa podwójnej precyzji, double; i - wartość całkowita, int). Uzupełnienie specyfikacji typu literą v oznacza, że parametry przekazywane są nie jako oddzielne wartości, lecz w postaci n-elementowego wektora. Przykładami użycia opisanej tu konwencji są nazwy: glLightfv(), glVertex3f() i glNormal4fv().
Sposób zdefiniowania parametrów oświetlenia sceny pokazano na wydruku 16.4.
Wydruk 16.4. Ustalenie parametrów oświetlenia sceny
// Ustaw kolor światła otaczającego na ciemnoszary.
float AmbientColor[4]={0.2f, 0.2f, 0.2f, 1.0f};
glLightfv(GL_LIGHT0, GL_AMBIENT, AmbientColor);
// Ustaw kolor składowej rozproszonej na jaśniejszy szary.
float DiffuseColor[4]={0.8f, 0.8f, 0.8f, 1.0f};
glLightfv(GL_LIGHT0, GL_DIFFUSE, DiffuseColor);
// Ustaw kolor składowej odbitej na biały.
float SpecularColor[4]={1.0f, 1.0f, 1.0f, 1.0f};
glLightfv(GL_LIGHT0, GL_SPECULAR, SpecularColor);
Zauważmy, że wartości określające kolor światła przekazywane są w postaci czteroelementowego wektora liczb rzeczywistych. Pierwsze trzy elementy określają wartości składowych: czerwonej, zielonej i niebieskiej, czwarty definiuje tzw. składową alfa, którą nie będziemy się tu zajmować (jej wartość ustalona jest na 1.0). Wartości każdej składowej muszą mieścić się w zakresie od 0.0 (brak światła) do 1.0 (pełna jaskrawość); wartości spoza tego zakresu są obcinane (poniżej zera do 0.0, powyżej 1 do 1.0). Użycie takiego modelu uniezależnia sposób użycia funkcji OpenGL od parametrów urządzenia - fizyczne wartości kolorów określane są przez system wewnętrznie, na podstawie parametrów kontekstu renderowania oraz kontekstu urządzenia GDI.
Używany w wywołaniu funkcji glLightfv() wektor LightPosition zawiera również cztery elementy, określające współrzędne x, y i z oraz odległość źródła światła od sceny (opis użycia tego parametru wykracza poza zakres naszej dyskusji, toteż poprzestaniemy na nadaniu mu wartości 0.0).
Podane tu wiadomości są zaledwie wprowadzeniem do opisu modeli oświetlenia dostępnych w standardzie OpenGL. Oprócz przedstawionego tu najprostszego źródła światła o charakterystyce ogólnokierunkowej OpenGL pozwala definiować źródła punktowe o zadanych kierunkach i kątach stożka światła. Szczegółowe informacje na ten temat można znaleźć w materiałach na końcu podrozdziału.
Etap 3 - przekształcenia 3D
Statyczna scena 3D może co prawda być całkiem atrakcyjna, jednak dopiero wprawienie jej w ruch ujawnia wizualny potencjał grafiki trójwymiarowej. Przemieszczanie obiektów w przestrzeni wymaga poddania ich odpowiednim przekształceniom. Przekształceniem (transformacją) geometrycznym nazywamy dowolną operację, zmieniającą wzajemne położenie punktów; do najczęściej używanych przekształceń należą: przesunięcie, obrót i jednokładność (zmiana skali).
Kolejka przekształceń - od współrzędnych 3D do pikseli
Przetwarzanie danych graficznych w systemie OpenGL (podobnie zresztą jak w większości innych rozwiązań obsługujących grafikę trójwymiarową) można podzielić na trzy zasadnicze etapy. Pierwszym krokiem jest zdefiniowanie położenia punktów w lokalnym układzie współrzędnych, tj. układzie określającym pierwotne położenie wierzchołków bryły względem ustalonego początku. Innymi słowy, każdy zbiór punktów tworzących bryłę dany jest w „prywatnym” układzie współrzędnych. Drugim krokiem jest przekształcenie współrzędnych lokalnych na globalne za pomocą odpowiednich operacji macierzowych. Globalny układ współrzędnych (ang. world coordinate system) pozwala na jednoznaczne określenie współrzędnych wszystkich punktów, tworzących wszystkie obiekty wchodzące w skład sceny, względem wspólnego początku. Wyświetlenie tak przekształconych punktów wymaga jeszcze rzutowania ich na płaszczyznę dwuwymiarową, odpowiadającą ekranowi monitora. Ostatnim etapem jest wyświetlenie obrazu dwuwymiarowego, realizowane przez OpenGL automatycznie z wykorzystaniem bieżących kontekstów renderowania i urządzenia.
Jak z tego wynika, zadania programisty sprowadzają się do skonfigurowania widoku (co opisano w punkcie „Tworzenie grafiki 3D w systemie OpenGL”), zdefiniowania współrzędnych punktów za pomocą funkcji z rodziny glVertex() oraz wykonania odpowiednich przekształceń. Nimi właśnie zajmiemy się w tym punkcie.
Przekształcenia punktów w przestrzeni
Trzy podstawowe przekształcenia geometryczne używane do zmiany położenia punktów w przestrzeni to: przesunięcie (ang. translation), obrót (ang. rotation) i jednokładność (skalowanie, ang. scaling). Wymagają one określenia położenia punktu w układzie współrzędnych lokalnych.
Przesunięcie
Zasadę przesunięcia obrazuje rysunek 16.3.
Rysunek 16.3. Przesunięcie punktu
Translation by <2, 3> - Przesunięcie o wektor [2, 3]
Jak sama nazwa wskazuje, przesunięcie sprowadza się do liniowego przemieszczenia punktów o zadany wektor. Odpowiednią funkcją OpenGL jest glTranslatef(), której argumenty określają współrzędne x, y i z wektora przesunięcia. Jej działanie sprowadza się do zmiany współrzędnych punktów o zadane wartości. Oto przykład:
glTranslatef(100.0f, 200.0f, 300.0f);
Jednokładność
Jednokładność (skalowanie) przekształca współrzędne punktu poprzez przemnożenie ich przez zadane współczynniki (oddzielne dla każdej osi układu). Na przykład przekształcenie punktu o współrzędnych (-5, -2, 4) z użyciem wektora jednokładności [2, -1, 3] da w wyniku punkt o współrzędnych (-10, 2, 12). Przekształcenie przez jednokładność realizuje funkcja OpenGL glScalef():
glScalef(2.0f, 2.0f, 1.0f);
Obrót
Zasadę obrotu przedstawiono na rysunku 16.4.
Rysunek 16.4. Obrót punktu
Rotation by 90º - Obrót o 90º
<-2, 2> X - [-2, 2] X
X <2, 2> - X [2, 2]
Obrót dokonywany jest względem osi określonej w stosunku do przekształconych współrzędnych lokalnych obracanych punktów. Oś definiowana jest wektorem o początku położonym w punkcie (0, 0, 0) układu współrzędnych i końcu położonym w punkcie o zadanych współrzędnych x, y i z. Na przykład wektor o wartościach [1, 0, 0] definiuje oś obrotu identyczną z osią OX układu współrzędnych. Do przekształcenia punktu przez obrót służy funkcja OpenGL glRotatef(), której parametrami są kąt obrotu oraz współrzędne wektora osi.
glRotatef(45.0f, 1.0f, 0.0f, 0.0f);
glRotatef(180.0f, 0.0f, 1.0f, 0.0f);
glRotatef(23.5f, 0.0f, 0.0f, 1.0f);
Powyższy fragment kodu obróci punkt kolejno o 45 stopni wzdłuż osi OX, 180 stopni wzdłuż osi OY i 23,5 stopnia wzdłuż osi OZ.
Kolejność przekształceń
Należy pamiętać, iż kolejność wykonywania przekształceń jest istotna - innymi słowy wykonanie obrotu po przesunięciu da wynik inny niż wykonanie przesunięcia po obrocie, nawet jeśli wektory przekształceń będą w obu przypadkach identyczne. Dzieje się tak dlatego, iż przesunięcie wykonywane jest w lokalnym układzie współrzędnych, a ten jest modyfikowany w wyniku dokonania obrotu.
Etap 4 - rysowanie obiektów pierwotnych
Rysowanie obiektów pierwotnych (tzw. prymitywów) obejmuje określenie właściwości materiału (tworzywa) oraz zdefiniowanie współrzędnych punktów, wyznaczających kształt obiektu. Z utworzonych w ten sposób obiektów pierwotnych komponuje się złożone całości, odwzorowujące np. rzeczywiste przedmioty.
Ustalenie właściwości materiału
Pojęcie materiału (ang. material) oznacza w terminologii OpenGL zestaw cech określających sposób rysowania (i wygląd) obiektu pierwotnego. Manipulowanie właściwościami materiału realizują funkcje z rodziny glMaterial(), których użycie ilustruje wydruk 16.5.
Wydruk 16.5. Definiowanie właściwości materiału
// poniższe parametry imitują błyszczącą gładką powierzchnię
// koloru czerwonego
float AmbientColor[4]={0.1f, 0.0f, 0.0f, 1.0f},
DiffuseColor[4]={0.8f, 0.0f, 0.0f, 1.0f}
SpecularColor[4]={1.0f, 1.0f, 1.0f, 1.0f};
// Ustaw kolor składowej otaczającej.
glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT, AmbientColor);
// Ustaw kolor składowej rozproszonej.
glMaterialfv(GL_FRONT_AND_BACK, GL_DIFFUSE, DiffuseColor);
// Ustaw kolor składowej odbitej.
glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, SpecularColor);
Zwróćmy uwagę na użytą powyżej stałą GL_FRONT_AND_BACK, nakazującą użycie tych samych właściwości dla przedniej i tylnej strony materiału. W razie potrzeby można użyć dla nich różnych właściwości (określenie strony umożliwiają stałe GL_FRONT i GL_BACK). Stałe: GL_AMBIENT, GL_DIFFUSE i GL_SPECULAR pozwalają zdefiniować współczynniki odbicia światła otaczającego, rozproszonego i odbitego, przy czym i tu użycie stałej GL_AMBIENT_AND_DIFFUSE umożliwia łączne zdefiniowanie pierwszych dwóch wartości.
Alternatywną metodą definiowania właściwości materiału (łatwiejszą, lecz mniej uniwersalną) jest użycie funkcji glColorMaterial(). Jej wywołanie nakazuje materiałowi przyjęcie bieżącego koloru i „śledzenie” jego ustawienia. Oto przykład:
glColorMaterial(GL_FRONT,GL_AMBIENT_AND_DIFFUSE);
// Kolor składowych rozproszonej i otaczającej dla wierzchniej
// strony materiału wszystkich rysowanych od tej chwili obiektów
// będzie "śledził" bieżące ustawienie koloru
// (zdefiniowane funkcją glColor()).
Od chwili wywołania funkcji glColorMaterial() właściwości materiału są determinowane kolejnymi wywołaniami funkcji glColor(), co ilustruje wydruk 16.6.
Wydruk 16.6. Definiowanie właściwości materiału za pomocą funkcji glColorMaterial() i glColor()
float ColorArray[4]={1.0f, 1.0f, 0.2f, 1.0f};
// Ustaw poszczególne wartości barw składowych.
glColor4f(1.0f, 0.5f, 0.2f, 1.0f);
// Kolor składowych rozproszonej i otaczającej dla wierzchniej
// strony materiału wszystkich rysowanych od tej chwili obiektów
// będzie "śledził" bieżące ustawienie koloru (zdefiniowane wyżej).
// Zmień ustawienia koloru zgodnie z tablicą ColorArray.
glColor4fv(ColorArray);
// Kolor składowych rozproszonej i otaczającej dla wierzchniej
// strony materiału wszystkich rysowanych od tej chwili obiektów
// będzie "śledził" bieżące ustawienie koloru (zdefiniowane w tablicy).
Warto jeszcze wyjaśnić, w jaki sposób funkcje OpenGL rozróżniają „wierzch” i „spód” materiału. Właściwość ta wynika z porządku punktów definiujących wielokąt, będący ścianą obiektu (tzw. kierunku wielokąta) - za „wierzch” (czyli stronę przednią) może być uznana strona, której kolejne wierzchołki uporządkowane są w kierunku matematycznie ujemnym (zgodnie z ruchem wskazówek zegara) lub matematycznie dodatnim (przeciwnie do ruchu wskazówek zegara; ustawienie domyślne), zaś do zadania odpowiedniego porządku wykorzystuje się funkcję glFrontFace():
// Powierzchnia o kierunku matematycznie ujemnym...
glFrontFace(GL_CW);
// ... i matematycznie dodatnim (ustawienie domyślne)
glFrontFace(GL_CCW);
Wskazówka
Każda zmienna stanu automatycznie uzyskuje wartość domyślną w chwili inicjalizacji systemu OpenGL, toteż jawne modyfikowanie zmiennych jest niezbędne tylko w przypadku, gdy chcemy zmienić parametry rysowania. Dla jasności kodu (i „na wszelki wypadek”) warto jednak explicite definiować wszystkie potrzebne w aplikacji zmienne stanu, poprawia to bowiem czytelność programu i wyjaśnia intencje programisty.
Definiowanie wektorów normalnych
Matematyczne określenie „normalna” oznacza prostą prostopadłą do płaszczyzny. W terminologii OpenGL słowo to ma nieco szersze znaczenie - co prawda nadal oznacza prostą zorientowaną względem płaszczyzny, jednak nie narzuca warunku „fizycznej” prostopadłości względem obiektu. Wektory normalne wykorzystywane są w OpenGL do definiowania ustawień oświetlenia. Jak pamiętamy z punktu „Modele oświetlenia i cieniowania” i rysunku 16.2, normalna do powierzchni definiuje sposób odbicia przez nią światła. Światło rozproszone odbijane jest przez powierzchnię we wszystkich kierunkach (przy czym jego natężenie zależy od kąta padania), natomiast w przypadku składowej odbitej kierunek odbicia jest ściśle określony, a kąt odbicia wyznacza właśnie normalna. Oznacza to, iż zmiana (względem wartości teoretycznej) kierunku wektora normalnego dla danej płaszczyzny zmienia warunki odbijania przez nią światła, a tym samym jej wygląd.
Jak można się domyślać, efektem zdefiniowania dla danej płaszczyzny kilku wektorów normalnych będzie złudzenie jej „wygięcia” (utraty płaskości). Ta właśnie technika wykorzystywana jest do modyfikowania wyglądu płaszczyzn w OpenGL. Użytkownik może zdefiniować oddzielny wektor normalny dla każdego wierzchołka wielokąta (ściany obiektu), zaś system automatycznie określa warunki oświetlenia i odbicia dla poszczególnych punktów ściany, wykorzystując w tym celu interpolację. Technika ta pozwala uzyskać złudzenie gładkości powierzchni przy wykorzystaniu modeli złożonych ze stosunkowo niewielu płaszczyzn.
Do zdefiniowania wektora normalnego służy funkcja glNormal(), której użycie przedstawiono na wydruku 16.7.
Wydruk 16.7. Definiowanie wektora normalnego za pomocą funkcji glNormal()
// Normalna w kierunku ujemnym wzdłuż osi OZ
float NormalArray[3]={0.0f, 0.0f, -1.0f};
// Normalna w kierunku dodatnim wzdłuż osi OX
// zdefiniowana pojedynczymi wartościami
glNormal3f(1.0f, 0.0f, 0.0f);
// Definicja normalnej wykorzystująca wektor
glNormal3fv NormalArray);
Powyższy fragment kodu jest raczej mało użyteczny w praktyce, bowiem w rzeczywistych zastosowaniach definicje wektorów normalnych są znacznie bardziej złożone. Znacznie bardziej przydatna byłaby funkcja wyliczająca wektor normalny dla danej płaszczyzny na podstawie współrzędnych wyznaczających ją trzech punktów. Wyznaczenie takiego wektora jest stosunkowo proste, a opiera się na operacji algebraicznej zwanej iloczynem wektorowym (ang. cross product). Zasadę obliczania iloczynu wektorowego ilustruje rysunek 16.5.
Rysunek 16.5. Wyznaczanie wektora normalnego z użyciem iloczynu wektorowego
Cross product/normal diagram - Wektor normalny (iloczyn wektorowy [v1-v0] i [v2-v0])
Kod funkcji obliczającej wektor normalny do płaszczyzny przedstawiono na wydruku 16.8.
Wydruk 16.8. Wyznaczanie wektora normalnego do płaszczyzny metodą iloczynu wektorowego
#define X 0
#define Y 1
#define Z 2
void WorkOutNormal(float PlanePoint0[3], float PlanePoint1[3],
float PlanePoint2[3], float NormalOut[3])
{
float VectorA[3], VectorB[3];
double Length, OneOverLength;
// Oblicz współrzędne dwóch wektorów leżących na płaszczyźnie
// i zaczepionych w jednym punkcie.
VectorA[X] = PlanePoint1[X] - PlanePoint0[X];
VectorA[Y] = PlanePoint1[Y] - PlanePoint0[Y];
VectorA[Z] = PlanePoint1[Z] - PlanePoint0[Z];
VectorB[X] = PlanePoint2[X] - PlanePoint0[X];
VectorB[Y] = PlanePoint2[Y] - PlanePoint0[Y];
VectorB[Z] = PlanePoint2[Z] - PlanePoint0[Z];
// Oblicz iloczyn wektorowy.
NormalOut[X] = VectorA[Y] * VectorB[Z] - VectorA[Z] * VectorB[Y];
NormalOut[X] = VectorA[Z] * VectorB[X] - VectorA[X] * VectorB[Z];
NormalOut[X] = VectorA[X] * VectorB[Y] - VectorA[Y] * VectorB[X];
// Sprowadź wektor normalny do jednostkowego.
Length = NormalOut[X] * NormalOut[X] + NormalOut[Y] * NormalOut[Y] +
NormalOut[Z] * NormalOut[Z];
// Zabezpiecz przed pierwiastkowaniem liczby ujemnej.
if(Length > 0)
OneOverLength = 1/sqrt(Length);
else
OneOverLength = (1/0.0001);
NormalOut[X] *= OneOverLength;
NormalOut[Y] *= OneOverLength;
NormalOut[Z] *= OneOverLength;
}
Sposób wykorzystania tak zdefiniowanej funkcji przedstawiono na wydruku 16.9.
Wydruk 16.9. Wykorzystanie wektora normalnego wyznaczonego za pomocą funkcji WorkOutNormal()
float Vertex0[3]={-1.0f, -1.0f, 0.0f},
Vertex1[3]={0.0f, 1.0f, 0.0f},
Vertex2[3]={1.0f, -1.0f, 0.0f};
float Normal[3];
// Oblicz teoretyczny wektor normalny do płaszczyzny
// przechodzącej przez punkty Vertex0, Vertex1 i Vertex2.
WorkOutNormal(Vertex0, Vertex1, Vertex2, Normal);
// Wektor Normal jest teraz prostopadły do płaszczyzny.
glNormal3fv(Normal);
// ciąg dalszy rysowania...
Funkcja WorkOutNormal() oblicza jedynie teoretyczny wektor normalny dla danej płaszczyzny, jednak w razie potrzeby zawsze można skorzystać z oferowanej przez OpenGL możliwości zdefiniowania dowolnego wektora normalnego. Do tworzenia takich wektorów w celu wizualnego „wygładzania” powierzchni można wykorzystać kilka metod, w większości opartych na uśrednianiu wektorów normalnych dla przyległych wielokątów. Ze względu na brak miejsca nie będziemy jednak rozwijać tego tematu.
Kolejkowanie operacji graficznych
W kilku kolejnych punktach zajmiemy się wewnętrznymi szczegółami definiowania i generowania sceny, w tym określaniem typu rysowanych elementów pierwotnych i sposobu ich tworzenia. Jak wspomniano wcześniej, obiekty pierwotne (prymitywy), takie jak punkty, odcinki i wypełnione wielokąty, są elementarnymi komponentami większych i bardziej skomplikowanych brył.
Instrukcje graficzne w systemie OpenGL nie są wykonywane bezpośrednio w chwili wywołania odpowiednich funkcji, lecz kolejkowane i realizowane w chwili udostępnienia odpowiednich zasobów obliczeniowych lub wywołania funkcji glFlush(). Jak sama nazwa wskazuje, wykonanie tej funkcji powoduje opróżnienie (ang. flush) kolejki instrukcji.
Wskazówka
Kolejkowanie nie zmienia porządku wykonywania instrukcji.
Definiowanie obiektów pierwotnych
W systemie OpenGL obiekty pierwotne (punkty, odcinki, wielokąty i ich zespoły) definiowane są poprzez określenie punktów, opisanych najczęściej trójką współrzędnych (x, y i z) w przestrzeni trójwymiarowej. Zdefiniowanie położenia punktu w przestrzeni umożliwia funkcja glVertex(), dostępna w kilku odmianach, różniących się postacią listy parametrów. Dwuparametrowe wersje funkcji glVertex() służą do definiowania obiektów dwuwymiarowych, dla których zakłada się współrzędną z równą zeru. Typ obiektu pierwotnego opisywanego punktami określa się poprzez przekazanie odpowiedniego parametru w wywołaniu funkcji glBegin(). Poszczególne punkty (zdefiniowane w układzie dwu- lub trójwymiarowym) definiowane są kolejnymi wywołaniami funkcji glVertex(), zlokalizowanymi pomiędzy wywołaniem funkcji glBegin() i glEnd():
// Rysuj trójkąt...
glBegin(GL_TRIANGLES);
glVertex3f(-10.0f, -10.0f, 0.0f);
glVertex3f(0.0f, 10.0f, 0.0f);
glVertex3f(10.0f, -10.0f, 0.0f);
glEnd();
Wywołania funkcji glBegin() i glEnd() można umieścić w dowolnym miejscu programu pod następującymi warunkami:
Pary wywołań nie mogą być zagnieżdżone (tj. pomiędzy wywołaniami glBegin() i glEnd() nie może nastąpić kolejne wywołanie glBegin().
Uprzednio zainicjalizowano system OpenGL oraz utworzono konteksty renderowania i urządzenia.
Każdemu wywołaniu funkcji glBegin() odpowiada wywołanie glEnd().
Punkty, odcinki i wielokąty
Wielokątem (ang. polygon lub potocznie poly) nazywamy każdy pierwotny obiekt graficzny (obszar zamknięty) wyznaczony co najmniej trzema punktami i posiadający niezerowe pole powierzchni. Punkt jest miejscem w przestrzeni trójwymiarowej, posiadającym zerowe wymiary. Odcinek to fragment linii prostej łączący dwa punkty. Obiekty te, a także kilka innych, opisano w tabeli 16.1 wraz z odpowiadającymi im stałymi symbolicznymi.
Tabela 16.1. Obiekty pierwotne w systemie OpenGL oraz odpowiadające im stałe
Stała |
Obiekt pierwotny |
GL_POINT |
Punkt |
GL_LINE |
Odcinek |
GL_TRIANGLE |
Trójkąt (zdefiniowany współrzędnymi trzech wierzchołków) |
GL_QUAD |
Czworokąt (zdefiniowany współrzędnymi czterech wierzchołków) |
GL_POLYGON |
n-kąt (wielokąt zdefiniowany współrzędnymi n punktów zawartymi pomiędzy wywołaniami glBegin() i glEnd()) |
GL_TRIANGLE_STRIP |
Ciąg n przylegających trójkątów (zdefiniowany współrzędnymi n+2 wierzchołków) |
GL_QUAD_STRIP |
Ciąg n przylegających czworokątów (zdefiniowany współrzędnymi 2n+2 wierzchołków) |
Rysowanie obiektów pierwotnych
Kreślenie obiektów pierwotnych za pomocą funkcji OpenGL jest zadaniem bardzo prostym. Każdy obiekt definiowany jest poprzez podanie współrzędnych w układzie --> lokalnym[Author:ts] . Przykładowy kod źródłowy, rysujący oświetloną kostkę sześcienną koloru czerwonego, przedstawiono na wydruku 16.10.
Wydruk 16.10. Rysowanie oświetlonego sześcianu
// Uwaga: przed rozpoczęciem rysowania należy utworzyć konteksty
// i zdefiniować właściwości materiałów.
// Ustaw kolor płaszczyzn na czystą czerwień.
glColor3f(1.0f, 0.0f, 0.0f);
// Najpierw należy określić rodzaj tworzonych obiektów.
// W naszym przypadku tworzymy czworokąty (GL_QUAD).
glBegin(GL_QUADS);
// przednia
glNormalf(0.0f, 0.0f, 1.0f);
glVertex3f(-1.0f, 1.0f, -1.0f);
glVertex3f(1.0f, 1.0f, -1.0f);
glVertex3f(1.0f, -1.0f, -1.0f);
glVertex3f(-1.0f, -1.0f, -1.0f);
// tylna
glNormalf(0.0f, 0.0f, -1.0f);
glVertex3f(1.0f, 1.0f, 1.0f);
glVertex3f(-1.0f, 1.0f, 1.0f);
glVertex3f(-1.0f, -1.0f, 1.0f);
glVertex3f(1.0f, -1.0f, 1.0f);
// prawa
glNormalf(1.0f, 0.0f, 0.0f);
glVertex3f(1.0f, 1.0f, -1.0f);
glVertex3f(1.0f, 1.0f, 1.0f);
glVertex3f(1.0f, -1.0f, 1.0f);
glVertex3f(1.0f, -1.0f, -1.0f);
// lewa
glNormalf(-1.0f, 0.0f, 0.0f);
glVertex3f(-1.0f, 1.0f, -1.0f)
glVertex3f(-1.0f, 1.0f, 1.0f);
glVertex3f(-1.0f, -1.0f, 1.0f);
glVertex3f(-1.0f, -1.0f, -1.0f);
// górna
glNormalf(0.0f, 1.0f, 0.0f);
glVertex3f(-1.0f, 1.0f, 1.0f);
glVertex3f(1.0f, 1.0f, 1.0f);
glVertex3f(1.0f, 1.0f, -1.0f);
glVertex3f(-1.0f, 1.0f, -1.0f);
// dolna
glNormalf(0.0f, -1.0f, 0.0f);
glVertex3f(-1.0f, -1.0f, -1.0f);
glVertex3f(1.0f, -1.0f, -1.0f);
glVertex3f(1.0f, -1.0f, 1.0f);
glVertex3f(-1.0f, -1.0f, 1.0f);
glEnd();
// Zakończ operacje rysowania.
glFlush();
Jak łatwo zauważyć, program zawiera wielokrotne wywołania funkcji glVertex3f(), definiującej położenie wierzchołków bryły. Metoda taka jest mało wydajna i równie mało elegancka, ale na szczęście biblioteka OpenGL udostępnia inne metody definiowania wierzchołków (np. funkcja --> glVertexPointer()[Author:ts] ; omawianie tych metod wykracza niestety poza zakres rozdziału). Alternatywną metodę wykreślenia sześcianu pokazano na wydruku 16.11.
Wydruk 16.11. Rysowanie obiektu z wykorzystaniem tablicy wierzchołków
// Definicje tablic...
// Wierzchołki
float Verts[8][3]=
{
{-1.0f, 1.0f, -1.0f},
{1.0f, 1.0f, -1.0f},
{1.0f, -1.0f, -1.0f},
{-1.0f, -1.0f, -1.0f},
{1.0f, 1.0f, 1.0f},
{-1.0f, 1.0f, 1.0f},
{-1.0f, -1.0f, 1.0f},
{1.0f, -1.0f, 1.0f}
};
// Obiekty pierwotne
// (tablica wskaźników do elementów tablicy Verts
// definiujących czworokąty)
int Polys[6][3]=
{
{0, 1, 2, 3},
{4, 5, 7, 6},
{1, 4, 7, 2},
{5, 0, 3, 6},
{5, 4, 1, 0},
{3, 2, 7, 6}
};
// wektory normalne do czworokątów
float Normals[6][3]=
{
{0.0f, 0.0f, -1.0f},
{0.0f, 0.0f, 1.0f}
{1.0f, 0.0f, 0.0f},
{-1.0f, 0.0f, 0.0f},
{1.0f, 0.0f, 0.0f},
{-1.0f, 0.0f, 0.0f}
};
// Rysuj sześcian.
int i;
glColor3f(1.0f, 0.0f, 0.0f);
glBegin(GL_QUADS);
for(i=0; i<6; i++)
{
glNormalfv(Normals[i]);
glVertex3fv(Verts[Polys[i][0]]);
glVertex3fv(Verts[Polys[i][1]]);
glVertex3fv(Verts[Polys[i][2]]);
}
glEnd();
// Zakończ wszystkie operacje graficzne.
glFlush();
W przypadku konieczności definiowania większej liczby punktów (tzw. siatek (ang. mesh), czyli powierzchni złożonych z przylegających do siebie obiektów pierwotnych) metoda ta pozwala znacznie poprawić czytelność kodu źródłowego, co jednak nie przekłada się w żaden sposób na wydajność kodu wynikowego. Poprawę wydajności, wynikającą z wielokrotnego użycia tego samego kodu, uzyskuje się natomiast w przypadku generowania kolejnych siatek.
Wydajniejsze generowanie obiektów - listy wyświetlania
Alternatywą dla opisanej poprzednio metody jest użycie tzw. list wyświetlania (ang. display list), umożliwiających bardziej zwięzłe definiowanie obiektów. Lista wyświetlania jest rodzajem makrodefinicji, przechowującej ciąg (listę) instrukcji graficznych i umożliwiającej wywołanie takiego ciągu w dowolnym miejscu programu za pomocą pojedynczego polecenia.
Oprócz poprawy zwięzłości i czytelności zaletą list wyświetlania jest też większa wydajność w stosunku do analogicznych sekwencji „dosłownych” wywołań funkcji. Każda lista opatrywana jest w chwili tworzenia identyfikatorem, którego wartość można zdefiniować samemu lub uzyskać od systemu OpenGL (korzystanie z drugiej metody jest zalecane, zwalnia bowiem programistę od konieczności sprawdzania dublujących się identyfikatorów).
Pierwszym krokiem do utworzenia listy wyświetlania jest pobranie od systemu jej identyfikatora. Służy do tego funkcja glGenLists(), której parametrem jest liczba żądanych identyfikatorów. Funkcja zwraca wartość pierwszego identyfikatora (ID) w grupie; pozostałe wartości są kolejno o jeden większe (ID+1, ID+2 itd.).
Po ustaleniu wartości identyfikatora można rozpocząć rejestrowanie kolejnych operacji tworzących listę, do czego służy funkcja glNewList(). Pierwszy z jej dwóch parametrów jest identyfikatorem tworzonej listy, zaś drugi określa, czy wchodzące w jej skład instrukcje mają zostać tylko zarejestrowane (GL_COMPILE), czy zarejestrowane i wykonane (GL_COMPILE_AND_EXECUTE). Kolejne wywołania funkcji, umieszczone w kodzie pomiędzy wywołaniami glNewList() i glEndList(), zostaną umieszczone na liście (kilku mniej popularnych funkcji OpenGL nie daje się w ten sposób wykorzystywać - szczegóły na ten temat można znaleźć w dokumentacji biblioteki).
Utworzoną w ten sposób listę można wywołać za pomocą funkcji glCallList(). Przykład konstrukcji i wykorzystania listy przedstawiono na wydruku 16.12.
Wydruk 16.12. Utworzenie i wywołanie listy wyświetlania
int ListID;
// Pobierz dostępny identyfikator listy.
ListID = glGenLists(1);
// Początek tworzenia listy
glNewList(ListID, GL_COMPILE);
// ... instrukcje OpenGL ...
// Koniec tworzenia listy
glEndList();
// ... inne instrukcje ...
// Wywołanie listy
glCallList(ListID);
Etap 5 - wymiana buforów
Ostatni etap rysowania składa się z wywołania tylko jednej funkcji - SwapBuffers(). Jej użycie demonstruje funkcja UpdateOpenGLScene(), zawarta w programie przykładowym opisanym w następnym punkcie.
Przykładowy program wykorzystujący funkcje OpenGL
Demonstrację większości opisanych w tym podrozdziale mechanizmów można znaleźć w przykładowym programie zawartym w katalogu OpenGL na dołączonej do książki płycie CD-ROM. Plik projektu nosi nazwę OpenGLExample.bpr, zaś sam program jest prostym modelem Układu Słonecznego. Zastosowano w nim strukturę hierarchiczną, której centralnym punktem jest Słońce, wokół którego krążą poszczególne planety, wokół tych z kolei - ich księżyce itd. (hierarchia może teoretycznie zawierać nieskończoną liczbę poziomów). Model każdego z orbitujących ciał niebieskich posiada następujące właściwości:
orbitalną prędkość kątową wyrażoną w stopniach na jednostkę czasu;
bieżące położenie na orbicie wyrażone w stopniach;
promień orbity wyrażony w jednostkach długości;
promień własny wyrażony w jednostkach długości;
kolor wyrażony w postaci składowych RGBA;
opis tzw. kwadryki (ang. quadric), definiującej kształt modelu;
opis księżyców (tworzących kolejny poziom hierarchii).
Zasada działania programu jest bardzo prosta. W pierwszym etapie tworzony jest kontekst urządzenia i związany z nim kontekst renderowania. Następnym krokiem jest zdefiniowanie źródła światła, odpowiadającego gwieździe centralnej (Słońcu). Kolejna czynność to utworzenie modelu układu planetarnego (modelowanie księżyców poszczególnych planet pozostawiamy Czytelnikom). Warto zauważyć, że proporcje promieni Słońca i orbit nie odpowiadają wartościom rzeczywistym; aby uzyskać bardziej realistyczny obraz Układu Słonecznego, można nadać stałym DISTANCE_DIV, SUN_RADIUS_DIV i RADIUS_DIV wartość 500000.0f.
Rysowanie modelu wykonywane jest w funkcji obsługi stanu jałowego, którą w naszym programie jest UpdateOpenGLScene(). Kolejne operacje wykonywane przez UpdateOpenGLScene() to:
wyczyszczenie ekranu;
inicjalizacja macierzy przekształcenia;
przekształcenie układu współrzędnych stosownie do odległości i kąta patrzenia obserwatora;
wykreślenie położenia gwiazd;
wyczyszczenie bufora głębokości (pozwoli to na „nałożenie” kolejnych obrazów na tło gwiazd);
wywołanie funkcji rysującej Układ Słoneczny (sprowadza się to do wywołania funkcji kreślącej Słońce, która rekurencyjnie wywoła odpowiednie funkcje dla pozostałych elementów modelu);
wywołanie funkcji kreślącej orbity;
opróżnienie systemowej kolejki operacji graficznych;
wymiana buforów.
Za synchronizację ruchu poszczególnych planet odpowiedzialny jest zegar (komponent TTimer), uruchamiany po kliknięciu przycisku Start. Do kontrolowania prędkości ruchu planet służy suwak Prędkość. Dokładne objaśnienia poszczególnych operacji zawarto w komentarzach umieszczonych w kodzie źródłowym.
Program przeznaczony jest do pracy w trybie koloru 24-bitowego (16 milionów kolorów) i najlepiej uruchamiać go w takiej właśnie konfiguracji ekranu. Nie będzie on pracował poprawnie w trybach 256-kolorowych, wykorzystujących palety.
Oprócz plików źródłowych projekt zawiera także dwie biblioteki importowe: OpenGL32.lib i Glu32.lib. Można je znaleźć w podkatalogu Lib\Psdk katalogu macierzystego systemu C++Builder.
W trakcie pracy programu można manipulować widokiem za pomocą myszy. Przesuwanie myszy przy naciśniętym lewym przycisku pozwala na obracanie widoku, przesuwanie jej przy naciśniętym prawym przycisku umożliwia zmianę skali, zaś przesuwanie przy naciśniętych obu przyciskach pozwala na przemieszczanie widoku w poziomie i w pionie.
Podsumowanie
Programowanie trójwymiarowych operacji graficznych z użyciem biblioteki OpenGL jest zagadnieniem bardzo obszernym; napisano na jego temat wiele opasłych książek. W niniejszym rozdziale omówiliśmy zaledwie podstawowe aspekty OpenGL, zaś niektóre tematy, jak np. wykorzystanie tekstur, pominięto tu całkowicie (w zamian udało się zaprezentować przykładowy program). Przedstawione tu wiadomości powinny jednak dać Czytelnikom wystarczające podstawy do rozpoczęcia przygody z grafiką trójwymiarową, wykorzystującą bibliotekę OpenGL.
Materiały uzupełniające
Osobom zainteresowanym zagadnieniami grafiki 3D oraz wykorzystaniem biblioteki OpenGL można polecić następujące źródła informacji:
Witryna WWW grupy roboczej OpenGL (http://www.opengl.org)
Doskonałe źródło wszelkich informacji, od wiadomości dla nowicjuszy do najświeższych nowinek technicznych. Można tam również znaleźć obszerny zestaw łączy do innych stron.
Lista dyskusyjna OpenGL (aby się zapisać, należy wysłać na adres ListGuru@fatcity.com list o temacie SUBSCRIBE OPENGL-GAMEDEV-L )
Szybkie odpowiedzi na wszelkie pytania (bardziej lub mniej techniczne).
Richard S. Wright, Michael Sweet, OpenGL. Księga eksperta, Helion, Gliwice 1999, ISBN 83-7197-093-5
Książka ta, znana jako „Błękitna księga” (The Blue Book), jest przystępnym opisem standardu OpenGL opracowanego przez Microsoft. Przeznaczona jest ona przede wszystkim dla programistów piszących aplikacje dla Windows (zawiera CD).
The OpenGL Architecture Review Board (Mason Woo, Jackie Neider i Tom Davis) OpenGL Programming Guide, Second Edition, Addison-Wesley, 1999, ISBN 0-201-46138-2
Wyczerpująca encyklopedia standardu OpenGL (tzw. Czerwona księga, The Red Book), napisana przez samych jego twórców. Pozycja ta, chociaż dość zaawansowana jak na potrzeby przeciętnego programisty, jest chyba najpełniejszym podręcznikiem OpenGL dostępnym na rynku.
Wprowadzenie do standardu DirectX
Nazwa DirectX określa opracowany przez Microsoft standard wysokowydajnych procedur dostępu do urządzeń multimedialnych. Człon „Direct” (ang. bezpośredni) oznacza, że funkcje dostępne poprzez interfejs DirectX odwołują się bezpośrednio do sterowników urządzeń. Chociaż głównym przeznaczeniem DirectX było programowanie gier komputerowych, standard ten wykorzystywany jest także w innych programach, wymagających wydajnych mechanizmów obsługi animowanej grafiki i odtwarzania dźwięku. DirectX jest rozwiązaniem nieprzenośnym, przeznaczonym wyłącznie dla systemów Microsoftu (Windows 9x, Millennium, NT i 2000).
DirectX a model COM
System DirectX opiera się w znacznym stopniu na opracowanym przez Microsoft modelu obiektowym COM (Component Object Model). Mówiąc najprościej, architektura COM udostępnia programistom obiekty (podobne do obiektów znanych z C++), które można równolegle wykorzystywać w wielu aplikacjach jednocześnie. Klasą bazową dla wszystkich interfejsów obiektów COM jest IUnknown, udostępniająca trzy metody:
AddRef(), inkrementującą licznik odwołań (wewnętrzny licznik informujący, ilu klientów komunikuje się z obiektem);
Release(), dekrementującą licznik odwołań;
QueryInterface(), udostępniającą interfejs obiektu.
Interfejs programowy DirectX jest stosunkowo prosty, toteż korzystanie z jego wywołań wymaga jedynie minimalnej znajomości mechanizmów COM.
Uwaga
Szczegółowe omówienie modelu COM zawiera rozdział 12.
-->
[Author:ts]
Nieobiektowe funkcje DirectX
Standard DirectX obejmuje również funkcje nie związane z modelem COM, ani innymi interfejsami. Skupiają się one (z niewielkimi wyjątkami) głównie wokół mechanizmów inicjalizacyjnych. Korzystanie z wywołań DirectX wymaga również użycia kilku „ogólnych” funkcji COM:
CoInitialize(), przygotowującej odwołanie do obiektu COM;
CoCreateInstance(), tworzącej wystąpienie (instancję) danego obiektu COM.
Wykorzystanie obu funkcji ilustruje wydruk 16.14.
Interfejs DirectDraw
W tym punkcie przedstawimy wprowadzenie do funkcji DirectDraw - podzbioru funkcji interfejsu DirectX przeznaczonego do obsługi rastrowej grafiki dwuwymiarowej.
Inicjalizacja obiektu DirectDraw
Do utworzenia obiektu DirectDraw służy funkcja DirectDrawCreate(), której parametrami są identyfikator GUID sterownika urządzenia i adres wskaźnika do tworzonego obiektu (trzeci parametr w obecnej implementacji (wersja 8.0) musi być równy NULL - przyp. tłum.). Identyfikator GUID (ang. Globally Unique Identifier - globalnie niepowtarzalny identyfikator) jest wartością, w jednoznaczny sposób identyfikującą graficzne urządzenie wyjściowe. Użycie zamiast niego wartości NULL nakazuje wybranie tzw. urządzenia podstawowego (ang. primary device), którym na ogół jest główna karta graficzna zainstalowana w komputerze. Możliwe jest także zażądanie automatycznej identyfikacji (ang. enumeration) obecnych i zarejestrowanych w systemie urządzeń; służy do tego funkcja DirectDrawEnumerate(). Jej omawianie wykracza poza ramy tego rozdziału; zainteresowanych Czytelników odsyłamy do dokumentacji SDK i literatury uzupełniającej.
Utworzenie obiektu DirectDraw przedstawiono na wydruku 16.13.
Wydruk 16.13. Utworzenie obiektu DirectDraw
// Funkcje DirectX zwracają standardowo wartość typu HRESULT.
// Może ona wynosić DD_OK (powodzenie) lub zawierać kod błędu.
HRESULT DDError;
// Wskaźnik do obiektu DirectDraw
LPDIRECTDRAW tempDD;
// Utwórz obiekt DirectDraw.
DDError=DirectDrawCreate(NULL, &tempDD, NULL);
// Sprawdź wynik operacji makrem FAILED.
if(FAILED(DDError))
{
// Błąd!
return false;
}
Jak widać, operacja utworzenia obiektu DirectDraw jest prosta i nie wymaga korzystania z modelu COM. Ten ostatni przydaje się dopiero w sytuacji, gdy konieczne jest odwołanie się do interfejsu nowszej wersji systemu DirectX (w chwili dokonywania przekładu była to wersja 8.0 - przyp. tłum.). Procedura przedstawiona na wydruku 16.13 pozwala utworzyć „podstawowy” obiekt DirectDraw; sposób odwołania do alternatywnego interfejsu (po utworzeniu obiektu DirectDraw) obrazuje wydruk 16.14.
Wydruk 16.14. Odwołanie do alternatywnego interfejsu obiektu DirectDraw
// Wskaźnik do interfejsu DirectDraw2
LPDIRECTDRAW2 DirectDraw2Interface;
// Czy w systemie jest dostępny interfejs DirectDraw2?
DDError=tempDD->QueryInterface(IID_IDirectDraw2,
(LPVOID *)&DirectDraw2Interface);
// Nie - zwróć informację o błędzie.
if(FAILED(DDError))
{
return false;
}
// Przygotuj wywołania COM.
CoInitialize(NULL);
// Utwórz egzemplarz interfejsu DirectDraw2.
DDError=CoCreateInstance(CLSID_DirectDraw, NULL, CLSCTX_ALL,
IID_IDirectDraw2, (LPVOID *)&DirectDraw2Interface);
// Niepowodzenie - zwróć informację o błędzie.
if(FAILED(DDError))
{
return false;
}
// Zainicjalizuj interfejs DirectDraw dla urządzenia domyślnego (NULL).
DDError=DirectDraw2Interface->Initialize(NULL);
if(FAILED(DDError))
{
return false;
}
// Koniec wywołań COM.
CoUninitialize();
// Operacja zakończona pomyślnie.
return true;
Przedstawiony tu kod odwołuje się do interfejsu DirectDraw w wersji 2, zaś w przypadku jego nieznalezienia zwraca odpowiedni kod błędu. Znacznie lepszym rozwiązaniem byłoby oczywiście automatyczne wykorzystanie w programie najnowszej wersji interfejsu, jednak pozwolimy sobie pominąć to zagadnienie, jako że ograniczamy się tutaj do podstawowych elementów, dostępnych w każdej wersji standardu. Nie zmienia to faktu, że w praktycznych zastosowaniach zawsze warto korzystać z najnowszej wersji.
Definiowanie ustawień ekranu
Standard DirectDraw pozwala programiście zapomnieć o ograniczeniach związanych z bieżącymi ustawieniami pulpitu Windows, umożliwiając wybranie praktycznie dowolnego trybu pracy karty graficznej oraz działanie w trybie pełnoekranowym. Innymi słowy, projektant aplikacji ma do dyspozycji całą powierzchnię ekranu i może z nią robić, co zechce.
Przed zmodyfikowaniem ustawień ekranu (rozdzielczości poziomej, pionowej oraz liczby kolorów) należy ustalić tzw. poziom współpracy lub uprzywilejowania (ang. cooperative level). Parametr ten określa sposób traktowania aplikacji przez system operacyjny oraz zasady jej współpracy z innymi programami. Użycie stałej DDSCL_EXCLUSIVE pozwala przekazać aplikacji pełną kontrolę nad zawartością ekranu; zsumowanie bitowe (operatorem |) powyższej stałej z DDSCL_FULLSCREEN umożliwia przejście do trybu pełnoekranowego, zaś stała DDSCL_NORMAL nakazuje programowi pozostanie w trybie okienkowym (nie można jej używać wraz z DDSCL_EXCLUSIVE - przyp. tłum.). Oprócz wymienionych tu wartości dostępnych jest jeszcze kilka innych, mniej istotnych stałych.
Fragment kodu przedstawiony na wydruku 16.15 umożliwia przejście do trybu pełnoekranowego i przekazanie aplikacji wyłącznej kontroli nad zawartością ekranu.
Wydruk 16.15. Przejście do trybu pełnoekranowego z wyłącznością
bool SetExclusiveMode(HWND Window)
{
HRESULT DDError;
if(DirectDraw2Interface==NULL)
return(false);
// Włącz tryb pełnoekranowy z wyłącznością.
DDError = DirectDraw2Interface->SetCooperativeLevel(Window,
DDSCL_ALLOWREBOOT |
DDSCL_EXCLUSIVE |
DDSCL_FULLSCREEN);
// Niepowodzenie - zwróć informację o błędzie.
if(DDError!=DD_OK)
{
return(false);
}
return(true);
}
Uwaga
Użyta w wywołaniu funkcji SetCooperativeLevel() stała DDSCL_ALLOWREBOOT umożliwia użytkownikowi zamknięcie i ponowne uruchomienie systemu przez naciśnięcie klawiszy Alt+Ctrl+Delete.
Interfejs DirectDraw pozwala także na programową zmianę bieżącej rozdzielczości ekranu (tj. SetDisplayMode():
DirectDraw2Interface->SetDisplayMode(Width, Height, BitsPerPixel,
RefreshRate, Flags);
Parametry wywołania funkcji SetDisplayMode() opisano w tabeli 16.2.
Tabela 16.2. Parametry funkcji SetDisplayMode()
Parametr |
Znaczenie |
Jednostki |
Width |
Żądana rozdzielczość pozioma ekranu |
piksele |
Height |
Żądana rozdzielczość pionowa ekranu |
piksele |
BitsPerPixel |
Żądana głębia koloru (liczba bitów na piksel) |
bity na piksel |
RefreshRate |
Żądana częstotliwość odchylania pionowego (odświeżania zawartości ekranu); wartość 0 oznacza „nieistotna” |
herce [Hz] |
Flags |
Opcje |
- |
Aby zatem przełączyć kartę graficzną i monitor w tryb 640×480 pikseli przy 16 bitach na piksel, (High Color) należy użyć wywołania:
DirectDraw2Interface->SetDisplayMode(640, 480, 16, 0, 0);
Tryb ten jest obsługiwany przez praktycznie wszystkie dostępne obecnie karty graficzne i jest standardowo używany w grach komputerowych intensywnie wykorzystujących grafikę. Zakończenie działania programu powoduje automatyczne przywrócenie pierwotnych ustawień ekranu, co zwalnia programistę od konieczności jawnej zmiany trybu.
Uwaga
Funkcje DirectDraw umożliwiają ustalenie wszystkich trybów graficznych obsługiwanych przez daną kartę. Wykorzystanie tego mechanizmu przed podjęciem próby zmiany trybu daje większą pewność, że operacja się powiedzie (niepowodzenie kończy się na ogół utratą możliwości komunikacji z komputerem, bowiem zawartość ekranu staje się nieczytelna lub w ogóle znika - przyp. tłum.). Szczegółowe omawianie tej techniki wykracza jednak poza ramy naszej dyskusji.
Powierzchnie
Praktyczne zastosowanie obiektów DirectDraw sprowadza się do konfigurowania urządzeń wyjściowych celem przygotowania ich do użycia wydajnych funkcji graficznych DirectX. Właściwe rysowanie wykorzystuje tak zwaną powierzchnię, dostęp do której realizowany jest poprzez interfejs IDirectDrawSurface. Powierzchnia (ang. surface) może być traktowana jako umieszczony w pamięci odpowiednik ekranu, zaś w systemie C++Builder - płótna (czyli klasy TCanvas). Różnica polega głównie na szybkości obsługi - dostęp do powierzchni DirectDraw jest z grubsza tysiąckrotnie szybszy!
W celu utworzenia powierzchni należy wykorzystać wcześniej zainicjalizowany obiekt DirectDraw. Pierwszym krokiem jest utworzenie tzw. powierzchni podstawowej (ang. primary surface), czyli „planszy”, na której wykonywane są operacje rysunkowe i której zawartość jest ostatecznie wyświetlana na ekranie. Operacje rysowania na powierzchni mogą wykorzystywać tzw. pojedyncze lub podwójne buforowanie.
Uwaga
Użycie podwójnego buforowania oznacza de facto wykorzystanie dwóch powierzchni, z których jedna jest wyświetlana na ekranie, zaś druga w tym samym czasie jest aktualizowana, a po zakończeniu procesu aktualizacji zamieniana miejscami z pierwszą. Oczywistą zaletą tej metody (dobrze znanej programistom zajmującym się grafiką komputerową) jest ukrycie przed użytkownikiem procesu modyfikowania zawartości ekranu; w przypadku pojedynczego buforowania wszystkie operacje widoczne są „na żywo”, co nie zawsze jest efektem pożądanym (zwłaszcza jeśli proces aktualizacji rozpoczyna się np. od wyczyszczenia ekranu).
Zasadę podwójnego buforowania oraz sposób utworzenia bufora powierzchni podstawowej ilustruje fragment programu przedstawiony na wydruku 16.16.
Wydruk 16.16. Utworzenie bufora powierzchni podstawowej DirectDraw
// deklaracja globalna lub w klasie
LPDIRECTDRAWSURFACE3 pPrimarySurface,pBackBuffer;
bool CreateSurfaces(void)
{
HRESULT DDError;
// Poniższe struktury pozwalają zdefiniować właściwości powierzchni.
DDSURFACEDESC SurfaceDescription;
DDSCAPS BackBufferCaps;
if(DirectDraw2Interface==NULL)
return(false);
// Wyzeruj zawartość struktury.
ZeroMemory(&SurfaceDescription, sizeof(DDSURFACEDESC));
// Pole dwSize struktur musi zawsze zawierać poprawny
// rozmiar struktury.
SurfaceDescription.dwSize = sizeof(DDSURFACEDESC);
// Pole dwFlags określa, które pola struktury zawierają informacje.
SurfaceDescription.dwFlags = DDSD_CAPS | DDSD_BACKBUFFERCOUNT;
// Ustaw odpowiednie właściwości powierzchni:
SurfaceDescription.ddsCaps.dwCaps =
// powierzchnia główna (zawsze widoczna)
DDSCAPS_PRIMARYSURFACE |
// podwójne buforowanie
DDSCAPS_FLIP |
// powierzchnia złożona (związana z innymi powierzchniami -
// tutaj z buforem)
DDSCAPS_COMPLEX;
// Używamy pojedynczego bufora.
SurfaceDescription.dwBackBufferCount = 1;
// Utwórz powierzchnię.
DDError = DirectDraw2Interface->CreateSurface(&SurfaceDescription,
(LPDIRECTDRAWSURFACE *)&pPrimarySurface, NULL);
// Niepowodzenie - zwróć informację o błędzie.
if(FAILED(DDError))
{
return(false);
}
// Wyzeruj bufor drugoplanowy.
ZeroMemory(&BackBufferCaps, sizeof(DDSCAPS));
// Tworzony bufor jest buforem drugoplanowym.
BackBufferCaps.dwCaps = DDSCAPS_BACKBUFFER;
// Pobierz z powierzchni głównej adres powierzchni
// bufora drugoplanowego.
DDError = pPrimarySurface->GetAttachedSurface(&BackBufferCaps,
&pBackBuffer);
// W razie niepowodzenia usuń powierzchnię główną i zwróć
// informację o błędzie.
if(FAILED(DDError))
{
// Usuń powierzchnię główną.
pPrimarySurface->Release();
return(false);
}
return(true);
}
Rysowanie na powierzchniach DirectDraw za pomocą funkcji GDI
Proste operacje graficzne na powierzchniach DirectDraw najłatwiej realizować za pomocą funkcji systemowego interfejsu GDI. Cały cykl sprowadza się do pobrania kontekstu urządzenia związanego z powierzchnią, narysowania żądanych obiektów i zwolnienia kontekstu. Pobranie kontekstu urządzenia realizuje metoda GetDC() interfejsu IDirectDrawSurface3, zaś jego zwolnienie (obligatoryjne) - metoda IDirectDrawSurface3::ReleaseDC(). Samo rysowanie wykonuje się za pomocą standardowych funkcji GDI.
Przykład użycia funkcji GDI do wyprowadzenia tekstu na powierzchni DirectDraw przedstawiono na wydruku 16.17.
Wydruk 16.17. Wyprowadzenie tekstu na powierzchni DirectDraw za pomocą funkcji GDI
// deklaracja globalna lub w klasie
LPDIRECTDRAWSURFACE3 pPrimarySurface,pBackBuffer;
bool TextOutDD(int x, int y, char *szText, COLORREF color,
COLORREF backcolor)
{
// Kontekst urządzenia
HDC hDeviceContext;
// Wynik operacji (kod błędu)
HRESULT DDError;
if(pBackBuffer==NULL)
return(false);
// Pobierz kontekst urządzenia dla bufora drugoplanowego.
DDError = pBackBuffer->GetDC(&hDeviceContext);
// Niepowodzenie - zwróć informację o błędzie.
if(FAILED(DDError))
{
return(false);
}
// Ustaw kolor tła tekstu.
SetBkColor(hDeviceContext, backcolor);
// Ustaw kolor linii (atramentu) tekstu.
SetTextColor(hDeviceContext, color);
// Wyprowadź tekst.
::TextOut(hDeviceContext, x, y, szText, lstrlen(szText));
// Zwolnij kontekst urządzenia.
pBackBuffer->ReleaseDC(hDeviceContext);
// Operacja zakończona pomyślnie.
return(true);
}
Jak widać na wydruku, tekst wyprowadzany jest do bufora drugoplanowego (pBackBuffer), co oznacza, że nie pojawi się on bezpośrednio na ekranie. Wymiany buforów dokonuje się za pomocą metody Flip() interfejsu IDirectDrawSurface3. W przypadku utraty zawartości bufora funkcja ta zwraca odpowiedni kod błędu, co pozwala podjąć kroki mające na celu odtworzenie zawartości powierzchni. Pokazana na wydruku 16.18 funkcja FlipSurfaces() realizuje „bezpieczną” wymianę buforów z obsługą ewentualnych błędów.
Wydruk 16.18. Wymiana powierzchni podstawowych DirectDraw wykorzystujących podwójne buforowanie
// deklaracja globalna lub w klasie
LPDIRECTDRAWSURFACE3 pPrimarySurface,pBackBuffer;
void FlipSurfaces(void)
{
HRESULT DDError;
// Pętla zostanie zakończona w chwili zwrócenia kodu poprawnego
// wykonania lub zwrócenia przez DirectDraw informacji
// o trwającym rysowaniu.
while(true)
{
// Wymień bufory
DDError = pPrimarySurface->Flip(NULL, 0);
// Jeśli OK, można zakończyć pętlę.
if(!FAILED(DDError))
{
break;
}
// W przeciwnym przypadku sprawdź przyczynę błędu.
else if(DDError == DDERR_SURFACELOST)
{
// Powierzchnia została utracona - zainicjalizuj ją ponownie
CreateSurfaces();
// i odtwórz jej zawartość.
DDError = pPrimarySurface->Restore();
// Jeśli to wywołanie się nie powiodło,
// coś jest bardzo nie w porządku...
if(FAILED(DDError))
break;
}
// Jeśli rysowanie wciąż trwało, ponawiaj próby wymiany buforów.
// W przeciwnym razie...
else if(DDError != DDERR_WASSTILLDRAWING)
{
// zakończ działanie i wróć tu za chwilę.
break;
}
}
}
Wyświetlanie map bitowych na powierzchniach DirectDraw
Wyprowadzanie tekstu i rysowanie linii na powierzchniach DirectDraw to nie wszystko - bardzo pożądana byłaby możliwość wyświetlania map bitowych. Okazuje się, że i to zadanie da się wykonać bardzo prosto z pomocą komponentu TBitmap. Pierwszym etapem jest załadowanie mapy bitowej z pliku BMP (można też pobrać ją z zasobów aplikacji). Odpowiedni fragment kodu pokazano na wydruku 16.19.
Wydruk 16.19. Pobranie zawartości komponentu TBitmap z pliku BMP
bool TForm1::LoadBitmapIntoTBitmap(Graphics::TBitmap *&NewBitmap, AnsiString FileName)
{
// Przypisanie NULL pozwoli sprawdzić poprawność utworzenia obiektu TBitmap.
NewBitmap = NULL;
// Utwórz nowy obiekt.
NewBitmap = new Graphics::TBitmap();
// Załaduj zawartość pliku BMP.
NewBitmap->LoadFromFile(FileName);
if(NewBitmap==NULL)
return(false);
// Udało się.
return(true);
}
Warto zauważyć, że przekazywany do funkcji LoadBitmapIntoTBitmap() wskaźnik do obiektu klasy TBitmap nie może być zainicjalizowany - obiekt tworzony jest wewnątrz funkcji. Pomyślne wykonanie funkcji, sygnalizowane zwróceniem wartości true, oznacza, że pobrana z pliku zawartość mapy bitowej jest dostępna poprzez właściwość Canvas obiektu NewBitmap. Zmodyfikowanie funkcji tak, by pobierała mapę z zasobów aplikacji (na podstawie identyfikatora), nie nastręcza trudności - wystarczy zmienić drugi parametr na int ResourceID i zastąpić instrukcję
NewBitmap->LoadFromFile(FileName)
wierszem
NewBitmap->LoadFromResourceID(HInstance, ResourceID);
Kolejnym krokiem jest utworzenie powierzchni przechowującej mapę bitową, reprezentowanej przez interfejs klasy IDirectDrawSurface3. Odpowiedni fragment programu przedstawiono na wydruku 16.20.
Wydruk 16.20. Utworzenie powierzchni DirectDraw, zawierającej mapę bitową pobraną z komponentu TBitmap
bool CreateSurfaceFromTBitmap(LPDIRECTDRAWSURFACE3 *pSurface,
Graphics::TBitmap *Bitmap)
{
// Wskaźnik do nowej powierzchni
TCanvas *SurfaceCanvas;
// i jej opis
DDSURFACEDESC SurfaceDescription;
HRESULT DDError;
HDC hDeviceContext;
// Wyzeruj strukturę opisującą powierzchnię.
ZeroMemory(&SurfaceDescription, sizeof(DDSURFACEDESC));
// Ustaw jej rozmiar.
SurfaceDescription.dwSize = sizeof(DDSURFACEDESC);
// Definiujemy pola rodzaju, szerokości i wysokości powierzchni.
SurfaceDescription.dwFlags = DDSD_CAPS | DDSD_WIDTH | DDSD_HEIGHT;
// Tworzymy zwykłą powierzchnię drugoplanową.
SurfaceDescription.ddsCaps.dwCaps = DDSCAPS_OFFSCREENPLAIN;
// Wysokość i szerokość musi odpowiadać mapie bitowej.
SurfaceDescription.dwWidth = Bitmap->Width;
SurfaceDescription.dwHeight = Bitmap->Height;
// Utwórz powierzchnię.
DDError = DirectDraw2Interface->CreateSurface(&SurfaceDescription,
(LPDIRECTDRAWSURFACE *)pSurface, NULL);
// W razie niepowodzenia zwróć informację o błędzie.
if(FAILED(DDError))
{
return(false);
}
// W przeciwnym razie skopiuj mapę bitową na powierzchnię.
// Pobierz kontekst urządzenia powierzchni.
DDError = (*pSurface)->GetDC(&hDeviceContext);
// W razie niepowodzenia zwróć informację o błędzie.
if(FAILED(DDError))
{
return(false);
}
// Utwórz obiekt klasy TCanvas reprezentujący powierzchnię.
SurfaceCanvas = new TCanvas();
// Zwiąż go z kontekstem urządzenia powierzchni.
SurfaceCanvas->Handle = hDeviceContext;
// Rysuj mapę bitową.
SurfaceCanvas->Draw(0, 0, Bitmap);
// Zwolnij kontekst urządzenia.
(*pSurface)->ReleaseDC(hDeviceContext);
// Usuń obiekt TCanvas.
delete SurfaceCanvas;
// Udało się!
return(true);
}
Uwaga
Również i w tym przypadku przekazywany do funkcji wskaźnik pSurface nie może być zainicjalizowany.
Obecnie dysponujemy trzema powierzchniami: powierzchnią podstawową (wyświetlaną na ekranie monitora), powierzchnią drugoplanową (zawartą w buforze drugoplanowym) oraz powierzchnią (powierzchniami) zawierającą wyświetlaną mapę bitową. Pora złożyć te elementy w całość. Kod przedstawiony na wydruku 16.21 realizuje tysiąckrotne wyrysowanie mapy bitowej na powierzchni zawartej w buforze drugoplanowym.
Wydruk 16.21. Rysowanie mapy bitowej na powierzchni drugoplanowej
// Zadeklarowane globalnie lub w klasie; muszą być zaincjalizowane
// przed wywołaniem funkcji BitmapDemo()
LPDIRECTDRAWSURFACE3 pBitmapSurface;
LPDIRECTDRAWSURFACE3 pPrimarySurface, pBackBuffer;
bool TForm1::BitmapDemo(void)
{
// Licznik
int i;
// Współrzędne X i Y rysowanej mapy bitowej
int x, y;
// Największe współrzędne nie powodujące wyjścia mapy
// poza powierzchnię
int maxX, maxY;
// Dane o powierzchniach
DDSURFACEDESC BackBufferDesc, BitmapDesc;
// Kod błędu
HRESULT DDError;
// Wyzeruj strukturę.
ZeroMemory(&BackBufferDesc, sizeof(DDSURFACEDESC));
// Ustaw pole rozmiaru.
BackBufferDesc.dwSize=sizeof(DDSURFACEDESC);
// Odczytaj opis powierzchni drugoplanowej
// (potrzebne nam są jej wymiary).
DDError = pBackBuffer->GetSurfaceDesc(&BackBufferDesc);
// W razie niepowodzenia zwróć informację o błędzie.
if(FAILED(DDError))
return(false);
// Musimy również znać wymiary powierzchni przechowującej
// mapę bitową.
ZeroMemory(&BitmapDesc, sizeof(DDSURFACEDESC));
BitmapDesc.dwSize=sizeof(DDSURFACEDESC);
DDError = pBitmapSurface->GetSurfaceDesc(&BitmapDesc);
// W razie niepowodzenia zwróć informację o błędzie.
if(FAILED(DDError))
return(false);
// Oblicz największe współrzędne, dla których mapa bitowa
// zmieści się w całości
// na powierzchni drugoplanowej.
maxX=BackBufferDesc.dwWidth-BitmapDesc.dwWidth;
maxY=BackBufferDesc.dwHeight-BitmapDesc.dwHeight;
// Wykonaj 1000 przebiegów pętli.
for(i=0; i<1000; i++)
{
// Wybierz losowe współrzędne...
x=random(maxX);
y=random(maxY);
// ... i narysuj mapę na powierzchni drugoplanowej.
pBackBuffer->BltFast(x, y, pBitmapSurface, NULL,
DDBLTFAST_NOCOLORKEY);
}
return(true);
}
Warto zwrócić uwagę na użytą w powyższym kodzie metodę IDirectDrawSurface3::BltFast(). Pozwala ona skopiować prostokątny wycinek mapy bitowej z jednej powierzchni na drugą, jest dość uniwersalna i nieprawdopodobnie szybka. Jej deklarację przedstawiono poniżej, zaś parametry opisano w tabeli 16.3.
HRESULT --> IDirectDrawSurface3[Author:ts] ::BltFast(DWORD dwX, DWORD dwY, LPDIRECTDRAWSURFACE3 lpDDSrcSurface, LPRECT lpSrcRect, DWORD dwTrans);
Tabela 16.3. Parametry funkcji BltFast()
Parametr |
Znaczenie |
dwX |
Współrzędna x lewego górnego wierzchołka prostokąta docelowego |
dwY |
Współrzędna x lewego górnego wierzchołka prostokąta docelowego |
lpDDSrcSurface |
Wskaźnik do powierzchni DirectDraw, zawierającej kopiowaną mapę bitową |
lpSrcRect |
Adres struktury typu RECT, zawierającej współrzędne prostokąta źródłowego (użycie wartości NULL nakazuje skopiowanie całej zawartości powierzchni) |
dwTrans |
Opcje kopiowania. Parametr ten może przyjmować następujące wartości: |
Kolor przezroczysty (klucz koloru, ang. color key) oznacza kolor pikseli „pustych”, tj. nie podlegających kopiowaniu. Pozwala to na kopiowanie map bitowych o nieregularnych kształtach (nie będących prostokątami), co jest bardzo przydatne we wszelkiego rodzaju animacjach. Definicja koloru przezroczystego może obejmować pojedynczą wartość (np. kolor czarny) lub zakres wartości (np. od czarnego do czystego niebieskiego, co pokazano w przykładzie poniżej). Kod definiujący kolor przezroczysty dla danej powierzchni przedstawiono na wydruku 16.22.
Wydruk 16.22. Ustalenie koloru przezroczystego dla powierzchni DirectDraw
// Struktura przechowująca informację o kolorze przezroczystym.
DDCOLORKEY ColorKey;
// Wyzeruj strukturę.
ZeroMemory( &ColorKey, sizeof( DDCOLORKEY ) );
// Ustal granice zakresu kolorów (czarny do ciemnoniebieskiego).
ColorKey.dwColorSpaceLowValue = RGB(0, 0, 0);
ColorKey.dwColorSpaceHighValue = RGB(0, 0, 255);
// Ustaw kolory przezroczyste dla powierzchni.
DDError = Surface->SetColorKey(DDCKEY_COLORSPACE | DDCKEY_SRCBLT, &ColorKey);
// DDCKEY_COLORSPACE -> używamy zakresu kolorów.
// DDCKEY_SRCBLT -> definiujemy kolory dla obrazu źródłowego.
Przykładowy program wykorzystujący funkcje DirectDraw
W katalogu DirectDraw na dołączonej do książki płycie CD-ROM można znaleźć projekt przykładowego programu DirectDraw.bpr - prostej aplikacji demonstrującej wszystkie techniki omówione w tym podrozdziale. Po zainicjalizowaniu systemu DirectDraw oraz ustaleniu poziomu uprzywilejowania i parametrów ekranu program odczytuje wybrany plik BMP, rysuje jego zawartość tysiąc razy na powierzchni podstawowej, wyprowadza tekst informacyjny i wymienia bufory. Naciśnięcie dowolnego klawisza pozwala zakończyć pracę.
Warto zauważyć, iż na karcie Directories/Conditionals opcji projektu (okno wywoływane poleceniem Options z menu Project) do listy ścieżek do bibliotek (pole Library Path) dodano pozycję $(BCB)\lib\PSDK. Znajduje się tam plik biblioteki importowej ddraw.lib, wchodzący standardowo w skład pakietu C++Builder. Użycie takiego zapisu pozwala uniknąć bezwzględnej specyfikacji ścieżki do pliku. Tworząc własne aplikacje, wykorzystujące biblioteki DirectX, można dołączać odpowiednie pliki za pomocą polecenia Add to Project z menu Project (w wyświetlonym oknie otwarcia pliku należy wybrać typ Library File (.lib) i odszukać plik ddraw.lib w podkatalogu \Lib\PSDK katalogu macierzystego C++Buildera.
Podsumowanie
Przedstawione tu wiadomości na temat standardu DirectDraw to zaledwie wprowadzenie; rozszerzenie i praktyczne wykorzystanie zdobytej wiedzy pozostaje jak zwykle w rękach Czytelników. Mogą to być na przykład pełnoekranowe gry zręcznościowe; funkcje DirectDraw można też wykorzystać do obsługi grafiki okienkowej - metody są tu nieco odmienne, ale również niezbyt skomplikowane. Programując operacje graficzne, warto też pamiętać o możliwości wykorzystania funkcji OnIdle() do aktualizacji zawartości ekranu.
Interfejs DirectSound
DirectSound jest dźwiękowym odpowiednikiem graficznego standardu DirectDraw. W komputerach wyposażonych w sterowniki DirectX umożliwia on bezpośrednią komunikację z urządzeniami dźwiękowymi, zapewniając wysoką wydajność i prędkość działania (w praktyce oznacza to możliwość natychmiastowego odtwarzania dźwięku, bez zwłoki związanej z działaniem mechanizmów systemu operacyjnego).
Pomiędzy systemami DirectSound i DirectDraw istnieje kilka podobieństw:
oba rozwiązania oparte są na architekturze COM;
oba udostępniają dwa interfejsy główne - ogólny i wyspecjalizowany. W przypadku DirectDraw są to interfejsy IDirectDraw i IDirectDrawSurface, dla DirectSound - IDirectSound i IDirectSoundBuffer;
oba umożliwiają ustalenie poziomu uprzywilejowania, określającego zakres „władzy” aplikacji nad sprzętem.
Nie wdając się w dalsze dywagacje, przejdźmy do praktyki.
Inicjalizacja obiektu DirectSound
Do utworzenia obiektu DirectSound służy funkcja DirectSoundCreate(), której argumentami są wskaźnik do identyfikatora GUID sterownika urządzenia oraz adres --> wskaźnika [Author:md] do tworzonego interfejsu. Podobnie jak w przypadku DirectDraw, kolejnym krokiem po inicjalizacji jest ustalenie poziomu uprzywilejowania. Dla typowych aplikacji okienkowych używa się najczęściej stałej DSSCL_NORMAL, co pozwala odwoływać się do danego urządzenia także innym programom; w przypadku aplikacji pełnoekranowych (np. gier) wykorzystuje się na ogół stałą DSSCL_EXCLUSIVE, zapewniającą wyłączność dostępu. Procedurę inicjalizacji obiektu DirectSound i ustalenia poziomu uprzywilejowania przedstawiono na wydruku 16.23.
Wydruk 16.23. Inicjalizacja obiektu DirectSound
// Deklaracja globalna lub w obrębie klasy
LPDIRECTSOUND DirectSoundInterface;
// WindowHandle to uchwyt głównego okna aplikacji.
// Uwaga:
// W przypadku jednoczesnego korzystania z funkcji DirectDraw
// należy przekazać ten sam uchwyt okna.
//
bool InitializeDirectSound(HWND WindowHandle)
{
// Kod błędu
HRESULT DError;
// Odwołaj się do domyślnego urządzenia odtwarzającego dźwięk.
DError = DirectSoundCreate(NULL, &DirectSoundInterface, NULL);
// W razie błędu zwróć odpowiednią wartość.
if(FAILED(DError))
{
return(false);
}
// Ustaw zwykły poziom uprzywilejowania.
DError = DirectSoundInterface->SetCooperativeLevel(WindowHandle,
DSSCL_NORMAL);
// W razie błędu....
if(FAILED(DError))
{
// zwolnij interfejs IDirectSound
DirectSoundInterface->Release();
// i zwróć wartość false.
return(false);
}
// Udało się.
return(true);
}
Z interfejsem IDirectSound związany jest kolejny obiekt, tak zwany bufor podstawowy (ang. primary buffer), automatycznie tworzony i zarządzany przez mechanizmy DirectX. Bufor główny jest swoistym „pojemnikiem” na odtwarzane dane dźwiękowe; jego zawartość składana jest z odpowiednich zapisów zgodnie z instrukcjami programu. Próbki tworzące zapisy składowe przechowywane są w tzw. buforach pomocniczych (ang. secondary buffer), skąd przesyłane są do bufora podstawowego. Interfejs IDirectSound udostępnia metody pozwalające modyfikować parametry zapisu zawartego w buforze podstawowym (częstotliwość próbkowania, rozdzielczość w bitach na próbkę, liczba kanałów), jednak metody te, chociaż stosunkowo proste, nie zostaną tu omówione ze względu na brak miejsca. Zainteresowanych Czytelników odsyłamy do dokumentacji standardu DirectX.
Utworzenie bufora pomocniczego
Proces tworzenia bufora składa się z trzech etapów:
umieszczenia spróbkowanego zapisu (odczytanego z pliku lub zasobów aplikacji) w tablicy (buforze liniowym);
utworzenia bufora za pomocą odpowiedniej metody interfejsu IDirectSound;
przetworzenia spróbkowanych danych i umieszczenia ich w buforze.
Obecnie przedstawimy prosty program przykładowy, ilustrujący poszczególne etapy.
Etap 1 - pobranie próbek do tablicy
Kod przedstawiony na wydruku 16.24 umożliwia odczytanie danych dźwiękowych z pliku WAV i umieszczenie ich w tablicy. Zawartość tablicy przeniesiemy do bufora dźwiękowego w etapie trzecim. Operacja odczytania danych z pliku jest dość prosta.
Wydruk 16.24. Załadowanie zawartości pliku WAV do tablicy
bool LoadWAVFromFile(char *&Data, int &Size, char *FileName)
{
// Wskaźnik plikowy
FILE *fp;
// Długość pliku
fpos_t EndOfFilePos;
// Otwórz plik do odczytu w trybie binarnym.
fp = fopen(FileName, "rb");
// W razie błędu zwróć wartość false.
if(fp == NULL)
{
return(false);
}
// Znajdź koniec pliku....
fseek(fp, 0, SEEK_END);
// i ustal jego położenie w bajtach.
fgetpos(fp, &EndOfFilePos);
// Wróć do początku pliku.
fseek(fp, 0, SEEK_SET);
// Ustaw wartość Size.
Size=(int)EndOfFilePos;
// Utwórz bufor zawartości pliku.
Data = new char[(long)EndOfFilePos];
// Jeśli brakło pamięci...
if(Data == NULL)
{
// zamknij plik i zwróć wartość false.
fclose(fp);
return(false);
}
// Czytaj dane.
if((signed)fread(Data, 1, EndOfFilePos, fp) != EndOfFilePos)
{
// Jeśli odczyt się nie udał, zamknij plik
fclose(fp);
// zwolnij bufor
delete []Data;
// i zwróć wartość false.
return(false);
}
// Udało się - zamknij plik i zwróć kod sukcesu.
fclose(fp);
return(true);
}
Po wykonaniu powyższej funkcji w tablicy znakowej Data znajdzie się komplet danych odczytanych z pliki WAV, włączając w to nagłówek oraz próbki składające się na właściwy zapis.
Etap 2 - utworzenie bufora pomocniczego
Utworzenie bufora pomocniczego jest bardzo proste i sprowadza się do wywołania odpowiedniej metody IDirectSound::CreateSoundBuffer(). Jej drugim parametrem jest wskaźnik do tworzonego bufora. Odpowiednią funkcję przedstawiono na wydruku 16.25.
Wydruk 16.25. Utworzenie bufora pomocniczego DirectSound
// Deklaracja globalna lub w obrębie klasy
LPDIRECTSOUND DirectSoundInterface;
bool CreateSecondaryBuffer(LPDIRECTSOUNDBUFFER *DirectSoundBuffer,
DSBUFFERDESC &DSBufferDescription)
{
// Kod błędu
HRESULT DSError;
// Ustaw rozmiar struktury.
DSBufferDescription.dwSize = sizeof(DSBUFFERDESC);
// Ustaw opcje.
DSBufferDescription.dwFlags = DSBCAPS_STATIC | DSBCAPS_GLOBALFOCUS;
// Utwórz bufor próbek.
DSError =
DirectSoundInterface->CreateSoundBuffer(&DSBufferDescription,
DirectSoundBuffer, NULL);
// W razie błędu zwróć wartość false.
if(FAILED(DSError))
{
return(false);
}
return(true);
}
Zauważmy, że pole dwFlags struktury DSBUFFERDESC uzyskuje wartość równą sumie stałych DSBCAPS_STATIC i DSBCAPS_GLOBALFOCUS. Pierwsza z nich nakazuje traktowanie bufora jako statycznego (tj. zawierającego niezmienny blok próbek), druga zaś umożliwia odtwarzanie dźwięku również po przełączeniu aplikacji w tryb pracy drugoplanowej.
Etap 3 - ekstrakcja danych dźwiękowych z bufora pomocniczego
Ostatni etap jest najbardziej skomplikowaną częścią procedury, jednak do przetwarzania standardowych plików WAV można po prostu wykorzystać kod zaprezentowany poniżej. Przedstawiona na wydruku 16.26 funkcja sprawdza poprawność formatu danych zawartych w tablicy (będącej de facto obrazem pliku w pamięci), a następnie odczytuje z niej informacje o formacie oraz ciąg próbek, tworzący właściwy zapis dźwięku.
Wydruk 16.26. Ekstrakcja nagłówka i próbek dźwiękowych z bufora pomocniczego
bool ParseWavBuffer(char *&pBuffer, WAVEFORMATEX **ppWaveHeader,
unsigned char **ppbWaveData, DWORD *pdwWaveSize)
{
// Wskaźnik (DWORD) bieżącej pozycji w buforze
DWORD *pdw;
// Wskaźnik (DWORD) końca bufora
DWORD *pdwEnd;
// kod formatu RIFF
DWORD dwRiff;
// Kod typu pliku RIFF
DWORD dwType;
// Długość bloku danych
DWORD dwLength;
if(pBuffer==NULL)
return(false);
// Na wszelki wypadek wyzeruj wskaźniki.
if(ppWaveHeader!=NULL)
*ppWaveHeader=NULL;
if(ppbWaveData!=NULL)
*ppbWaveData=NULL;
if(pdwWaveSize)
*pdwWaveSize=0;
// Ustaw wskaźnik pozycji w buforze.
pdw=(DWORD *)pBuffer;
// Odczytaj identyfikator formatu.
dwRiff=*pdw++;
// Odczytaj długość danych.
dwLength=*pdw++;
// Odczytaj identyfikator typu pliku.
dwType=*pdw++;
// Czy plik jest w formacie RIFF?
if(dwRiff!=mmioFOURCC('R', 'I', 'F', 'F'))
{
return(false);
}
// Czy plik jest typu WAV?
if(dwType!=mmioFOURCC('W', 'A', 'V', 'E'))
{
return(false);
}
// Ustaw wskaźnik końca danych.
pdwEnd=(DWORD *)((unsigned char *)pdw+dwLength-4);
// Powtarzaj aż do wyczerpania danych.
while(pdw<pdwEnd)
{
// Odczytaj typ bloku.
dwType=*pdw++;
// Odczytaj długość bloku.
dwLength=*pdw++;
switch(dwType)
{
// Blok zawiera informację o formacie.
case mmioFOURCC('f', 'm', 't', ' '):
// Jeśli jeszcze nie odczytano informacji o formacie...
if(ppWaveHeader && !*ppWaveHeader)
{
// czy długość się zgadza?
if(dwLength<sizeof(WAVEFORMAT))
{
// nie - błąd!
return(false);
}
// Ustaw wskaźnik danych.
*ppWaveHeader=(WAVEFORMATEX *)pdw;
// Odczytano dane i nagłówek - koniec.
if((!ppbWaveData||*ppbWaveData) &&
(!pdwWaveSize ||* pdwWaveSize))
return(true);
}
break;
// Blok zawiera dane.
case mmioFOURCC('d', 'a', 't', 'a'):
// Jeśli jeszcze nie odczytano danych...
if((ppbWaveData && !*ppbWaveData) ||
(pdwWaveSize && !* pdwWaveSize))
{
// pobierz wskaźnik danych
if(ppbWaveData)
*ppbWaveData=(unsigned char *)pdw;
// i ich długość.
if(pdwWaveSize)
* pdwWaveSize =dwLength;
// Odczytano wszystkie dane - koniec.
if(!ppWaveHeader || *ppWaveHeader)
return(true);
}
break;
}
// Przejdź do granicy następnego słowa.
pdw=(DWORD *)((unsigned char *)pdw+((dwLength+1)&~1));
}
// Jeśli dotarliśmy tutaj, gdzieś po drodze wystąpił błąd.
return(false);
}
Integracja elementów programu
Pozostaje nam napisać funkcję łączącą przedstawione powyżej elementy w jedną całość. Pierwszym krokiem będzie odczytanie zawartości pliku WAV i umieszczenie nagłówka oraz właściwych danych dźwiękowych w oddzielnych buforach. Następnie utworzymy bufor podstawowy, reprezentowany przez interfejs IDirectSoundBuffer, zablokujemy go, umieścimy w nim dane i odblokujemy. Całą procedurę przedstawiono na wydruku 16.27.
Wydruk 16.27. Odczytanie zawartości pliku WAV do bufora DirectSound
bool LoadWAVIntoDirectSoundBuffer(LPDIRECTSOUNDBUFFER *DSBuffer,
char *FileName)
{
// Bufor odczytanego pliku WAV
char *WAVFile;
// Rozmiar odczytanego pliku WAV
int WAVFileSize;
// Nowy opis bufora DirectSoundBuffer
DSBUFFERDESC NewSoundBufferDescription;
// Próbki dźwiękowe odczytane z pliku
unsigned char *WaveData;
// Wskaźniki do zablokowanego bufora DirectSoundBuffer
LPVOID pMem1, pMem2;
// i rozmiary wskazywanych bloków
DWORD dwSize1, dwSize2;
// kod błędu
HRESULT DSError;
// Zainicjalizuj bufor.
WAVFile=NULL;
// Wyzeruj opis bufora.
ZeroMemory(&NewSoundBufferDescription, sizeof(DSBUFFERDESC));
// Odczytaj plik; w razie niepowodzenia zwróć wartość false.
if(!LoadWAVFromFile(WAVFile, WAVFileSize, FileName))
return(false);
// Jeśli nie da się odczytać danych z pliku,
// zwróć informację o błędzie.
if(!ParseWavBuffer(WAVFile, &NewSoundBufferDescription.lpwfxFormat,
&WaveData, &NewSoundBufferDescription.dwBufferBytes))
{
delete WAVFile;
return(false);
}
// Jeśli nie da się utworzyć bufora DirectSoundBuffer,
// zwróć informację o błędzie.
if(!CreateSecondaryBuffer(DSBuffer, NewSoundBufferDescription))
{
delete WAVFile;
return(false);
}
// Zablokuj bufor.
DSError =
(*DSBuffer)->Lock(0, NewSoundBufferDescription.dwBufferBytes,
&pMem1, &dwSize1, &pMem2, &dwSize2, 0);
// Błąd - usuń bufor i zwróć wartość false.
if(FAILED(DSError))
{
delete WAVFile;
return(false);
}
// Skopiuj pierwszy blok pamięci.
memcpy(pMem1, WaveData, dwSize1);
// Jeśli potrzeba, skopiuj drugi blok.
if(dwSize2!=0)
memcpy(pMem2, WaveData+dwSize1, dwSize2);
// Odblokuj pamięć.
(*DSBuffer)->Unlock(pMem1, dwSize1, pMem2, dwSize2);
// Zwolnij pamięć.
delete WAVFile;
// Operacja zakończona pomyślnie.
return(true);
}
Po zdefiniowaniu i wypełnieniu bufora reszta jest już banalnie prosta. Do odtworzenia zapisanego w nim dźwięku wystarczy wywołanie
DSError = DSBuffer->Play(res1, res2, how);
Pierwsze dwa parametry, res1 i res2, są zarezerwowane i muszą być równe zero. Parametr trzeci określa sposób odtworzenia dźwięku - wartość zero oznacza odtworzenie jednokrotne, zaś użycie stałej DSBPLAY_LOOPING nakazuje odtwarzanie dźwięku w nieskończonej pętli. Funkcja Play() zwraca wartość typu HRESULT, którą można wykorzystać jako argument makra FAILED(), sprawdzającego poprawność wykonania operacji.
Wadą opisanej tu metody jest fakt, iż powtórne wywołanie funkcji Play() w trakcie odtwarzania zapisu nie powoduje rozpoczęcia odtwarzania kolejnej „kopii”, lecz kontynuację już rozpoczętego odtwarzania. W przypadku zapisów przeznaczonych do jednorazowego odtworzenia (jak np. zapis mowy lub dźwiękowa sygnalizacja zdarzenia) nie jest to problemem, jednak czasami zachodzi potrzeba nałożenia na siebie i równoczesnego odtworzenia kilku kopii zapisu. Na szczęście interfejs IDirectSound umożliwia powielanie buforów, co pozwala na tworzenie i równoległe odtwarzanie kilku kopii tego samego zapisu dźwięku.
Program przykładowy - równoległe odtwarzanie kilku dźwięków
Demonstracją opisanych tutaj funkcji DirectSound jest przykładowy projekt DirectSound.bpr, zawarty w katalogu DirectSound na dołączonej do książki płycie CD. Kod źródłowy mechanizmu odtwarzającego zawarto w module BCBDirectSound.cpp. Po uruchomieniu program inicjalizuje system DirectSound, a następnie ustawia wskaźnik funkcji obsługi stanu jałowego aplikacji na zdefiniowaną w programie funkcję UpdatePlayingSounds(). Ta ostatnia cyklicznie przegląda kolejkę odtwarzanych dźwięków i usuwa bufory tych, których odtwarzanie zostało zakończone. Odtwarzane zapisy pobierane są z plików WAV, wybieranych przez użytkownika za pomocą polecenia Otwórz z menu Plik.
Podobnie jak w przypadku demonstracji funkcji DirectDraw, ustawienia projektu zawierają definicję ścieżki do plików bibliotek, $(BCB)\lib\PSDK, jednak tym razem wykorzystywana jest biblioteka importowa dsound.lib, dostarczana w pakiecie C++Builder. Aby dodać ją do nowo tworzonego projektu, należy wydać polecenie Add to Project z menu Project, w polu Pliki typu: okna otwarcia pliku wybrać Library File (.lib) i odszukać plik dsound.lib w podkatalogu \Lib\PSDK katalogu macierzystego C++Buildera.
Pozostałe elementy standardu DirectX
Oprócz omówionych tu interfejsów programowych DirectDraw i DirectSound, będących prawdopodobnie najprostszymi w użyciu elementami standardu DirectX, ten ostatni udostępnia kilka innych interfejsów, obsługujących rozmaite urządzenia i media. Oto one:
DirectInput - interfejs programowy umożliwiający szybką obsługę urządzeń wejściowych, jak klawiatura, mysz i joystick;
DirectPlay - interfejs programowy przeznaczony do tworzenia gier wyposażonych w możliwość pracy sieciowej (łączność poprzez sieć lokalną, Internet lub łącze szeregowe);
Direct3D - interfejs umożliwiający wydajną obsługę grafiki trójwymiarowej w czasie rzeczywistym i wykorzystujący wspomaganie sprzętowe (przeznaczony głównie do programowania gier).
Materiały uzupełniające
Zagadnienia związane ze standardem DirectX są bardzo obszerne, a dokładniejsze poznanie tego tematu wymaga zapoznania się z dodatkowymi źródłami informacji. Użyteczne mogą się tu okazać przede wszystkim materiały publikowane przez firmę Microsoft, a także książki niezależnych autorów. Kilka propozycji podano poniżej.
Pliki pomocy zawarte w bibliotece MSDN
Biblioteka MSDN, udostępniana przez Microsoft na zasadzie subskrypcji oraz dostarczana w pakiecie Visual Studio, zawiera kompletny opis wszystkich elementów standardu DirectX.
Lista dyskusyjna programistów DirectX (DIRECTXDEV@discuss.microsoft.com)
Lista ta skupia ekspertów w dziedzinie programowania z użyciem funkcji DirectX; głos zabierają tu również sami twórcy standardu.
MSDN DirectX Developer Center (http://msdn.microsoft.com/DirectX)
Główne źródło dostępnych w Internecie informacji o DirectX.
Bradley Bargen, Peter Donnelly: Inside DirectX, Microsoft Press 1998, ISBN 1-57231-696-9
Najobszerniejsza pozycja literaturowa obejmująca wykorzystanie interfejsów DirectDraw, DirectSound, DirectPlay i DirectInput.
Podsumowanie
W rozdziale tym przedstawiliśmy podstawowe zagadnienia związane z wykorzystaniem interfejsów programowych OpenGL, DirectDraw i DirectSound. Ilość miejsca nie pozwoliła rozwinąć omawianych tematów, toteż zainteresowanym Czytelnikom polecamy sięgnięcie do źródeł bardziej szczegółowych informacji (książki, strony WWW oraz listy dyskusyjne), które opisano na końcu każdego podrozdziału.
Przedstawione tu techniki mogą posłużyć jako podstawa do budowy bardziej złożonych rozwiązań, pozwalających wyposażyć aplikacje tworzone w C++Builderze w wydajne mechanizmy prezentacji graficznej i odtwarzania dźwięku. Konstruując aplikacje wykorzystujące funkcje DirectX, należy pamiętać, że warunkiem ich poprawnego działania jest obecność w systemie odpowiednich wersji sterowników urządzeń zgodnych ze standardem DirectX. Z tego też względu istotne jest sprawdzanie wartości zwracanych przez funkcje DirectX zajmujące się inicjalizacją obiektów, tworzeniem interfejsów itd. W przypadku problemów z działaniem urządzeń lub sterowników daje to szansę obsłużenia błędu i uniknięcia załamania aplikacji (lub całego systemu). W przypadku OpenGL błędne działanie funkcji nie powoduje tak drastycznych efektów, jednak wyświetlane na ekranie wyniki mogą dalece odbiegać od oczekiwań.
--> Ustawienie parametru done na false jest raczej niewskazane, powoduje bowiem zwiększenie konsumpcji mocy obliczeniowej przez aplikację - przyp. tłum.
Gra słów; wiggle (ang.) - dosłownie „wiercić się” - przyp. tłum.
Obiekty obsługujące mechanizmy DirectX będziemy tu dla uproszczenia nazywać „obiektami DirectDraw (DirectSound itd.)” - przyp. tłum.
11
to zdanie przeniosłem z akapitu poniżej, tu lepiej pasuje.
return true usuwam, to nie ma nic wspólnego z wywołaniem funkcji wglMakeCurrent().
oryginał - współrzędne prawego górnego rogu; podaję za dokumentacją SDK
w prototypie funkcji niekonsekwencja, opis też jest niepoprawny. Poprawiam zgodnie z dokumentacją SDK i OpenGL KE.
w dalszej części rozdziału nie ma mowy o układach współrzędnych; zapowiedź w tekście usuwam
oryginał: glVertexArrays()
tu trzeba uzupełnić tytuł rozdziału po jego przetłumaczeniu
orygnał: DirectDrawCreate()
w oryginale IDirectDrawSurface2::; zmieniam deklarację na zgodną z dokumentacją SDK.
To samo co popprzedni parametr?
oryginał: wskaźnik (ma być wskaźnik do wskaźnika, lplp...)
usuwam stąd specyfikator klasy (w oryginale jest, ale w innych funkcjach go nie ma, więc bądźmy konsekwentni).