W poprzedniej części uruchomiliśmy już naszą grę, która korzysta z OpenGL do wyświetlania grafiki. Dzisiaj zgłębimy się w to, co tak naprawdę ostatnio zrobiliśmy, oraz dodamy obsługę wyświetlania tekstów i menu.
OpenGL w LWJGL
Korzystanie z OpenGL było potraktowane dość tutorialowo w drugiej części serii — z uwagi na obszerność treści zdecydowaliśmy się wydzielić to i dzisiaj przyjrzymy się bliżej, co tak naprawdę robimy.
Trochę teorii
Aby zrozumieć, dlaczego robimy pewne rzeczy, zacznijmy od wyjaśnienia zasady działania biblioteki OpenGL od strony graficznej. Osoby po ASP, architekturze lub malujące w przeszłości mogą śmiało przeskoczyć do następnej sekcji ;)
Monitor komputera jest płaszczyzną dwuwymiarową, co nieulega wątpliwości. Jednocześnie zadaniem tej biblioteki jest odzwierciedlenie (możliwie bliskie rzeczywistości) trzech wymiarów w sposób najbardziej naturalny jak tylko się da. Z drugiej strony założeniem tej biblioteki jest wsparcie także modelowania 2D, w którym takie podejście byłoby mniej realistyczne (ponieważ obraz 2D byłby poddawany transfromacjom 3D).
Projekcje
OpenGL aby rozwiązać ten problem oferuje nam do wyboru tzw. projekcje — innymi słowy sposób patrzenia na świat. W przypadku obrazów 3D, najprostszym przybliżeniem tego jak należy reprezentować obiekty w 3D na płaskim ekranie jest stożek — oko (czy kamera) jest punktem, z którego ‘pole’ widzenia jest tym większe, im dalej obiekt się znajduje. Pozwala to generować wrażenie głębii i oszukiwać nasze oko.
W przypadku grafiki dwuwymiarowej, wygodniejsze jest traktowanie trzeciego wymiaru jako ‘kolejności’ widzenia obiektów, a nie ‘odległości’, która wpływa na proporcje. Służy do tego projekcja ortogonalna — czyli w uproszczeniu przedstawienie pola widzenia jako pudełka o równoległych ścianach a następnie zwykłym ‘spłaszczeniu’ go.
Macierze
W dokumentacji OpenGL wielokrotnie spotkasz się z macierzami, które obrazują określoną funkcję czy koncepcję. Ma to swoje uzasadnienie — macierze można bardzo łatwo wykorzystać do przedstawienia informacji o świecie 3D oraz o przekształceniach tego świata (np. obrocie o określony kąt itp). Takie operacje (obrót, pomniejszenie/powiększenie) sprowadzają się do właściwie jednej operacji — mnożenia macierzy (oczywiście uprzednio musimy je wygenerować). Ten sprytny zabieg pozwolił na znaczne przyspieszenie obliczeń graficznych w czasach kiedy moc obliczeniowa była trudno dostępna (a współcześnie pozwala wycisnąć jeszcze więcej ‘mocy’ z kart graficznych) — zwykły procesor musi wiedzieć jak wykonywać nie tylko dodawanie, odejmowanie czy mnożenie, ale też dzielenie czy pierwiastkowanie — z tego powodu jest układem bardzo uniwersalnym, nadającym się do każdego rodzaju obliczeń, który nie jest ‘wybitny’ w żadnym rodzaju obliczeń. GPU z kolei (czyli proces dedykowany do zastosowań graficznych) musie wiedzieć tylko jak szybko mnożyć macierze, dzięki czemu można zoptymalizować go tylko pod kątem tej operacji (to oczywiście trochę uproszczenie, ale nie aż tak duże). Można lubić matematykę lub nie, ale faktem jest, że dzięki niej możemy cieszyć się efektami, które są podobne do zdjęć, a które generuje mała czarna kostka gdzieś w środku komputera ;)
Ciekawostka: takie samo podejście wykorzystuje się w procesorach typu RISC, które znajdują zastosowanie w telefonach komórkowych i innych urządzeniach mobilnych — pozbywając się części operacji, które procesor jest w stanie wykonać, i optymalizując go pod kątem tylko części najważniejszych, można uzyskać układ mniejszy, prostszy i bardziej energooszczędny, którego wydajność w niektórych aspektach będzie mniejsza, ale te można ‘nadrobić’ odpowiednio modyfikując oprogramowanie tak, aby korzystało tylko z tych ‘szybszych’ operacji.
GL11 ?
Być może zastanowiło Cię także co to za klasa GL11, z której statycznych metod korzystamy? Otóż ta i podobne klasy odpowiadają poszczególnym wersjom specyfikacji OpenGL — GL11 to wersja 1.1 specyfikacji. Tworząc bardziej profesjonalne gry ma to znaczenie — w naszym przypadku nie za duże, nasza gra nie będzie ani wymagająca ani skomplikowana graficznie, podstawowe funkcje ze specyfikacji 1.1 są w zupełności wystarczające i raczej nie będzie potrzeby korzystania z innych. Zainteresowanych odsyłamy do dokumentacji OpenGL (funkcje nazywają się tak samo w LWJGL, jak ich odpowiedniki w OpenGL).
Co my tak właściwie zrobiliśmy?
Wróćmy teraz do kodu — czyli co tak naprawdę zrobiliśmy. Przeanalizujmy linijka po linijce kod, który inicjuje nasz tryb graficzny OpenGL.
GL11.glMatrixMode(GL11.GL_PROJECTION);
Ponieważ jak już wspomnieliśmy OpenGL reprezentuje wszystko na macierzach, większość jego operacji jest uniwersalna — np. obrót może dotyczyć kamery, ale może też dotyczyć tego, co już narysowaliśmy. Ta funkcja to swego rodzaju deklaracja z naszej strony, że wszystkie kolejne wywołania będą dotyczyły wybranego aspektu — w naszym przypadku projekcji.
GL11.glLoadIdentity();
To swego rodzaju ‘resetowanie’ — funkcja ta ‘czyści’ (przywraca do domyślnego stanu) macierz przekształceń. Dzięki temu mamy pewność, jaki jest stan początkowy, a więc także na jakiej podstawie dokonujemy przekształceń.
GL11.glOrtho(0, 640, 480, 0, 1, -1);
To bardzo ważna linijka, ponieważ mówi ona OpenGL, że do projekcji należy zastosować przekształcenie ortogonalne (‘pudełko’, o którym pisaliśmy trochę wyżej, w części teoretycznej). Kolejne parametry określają zakres tego, co widzimy, w kolejności:
- lewa krawędź — oś X
- prawa krawędź — oś X
- dolna krawędź — oś Y
- górna krawędź — oś Y
- ‘bliska’ granica widzenia — oś Z
- ‘daleka’ granica widzenia — oś Z
Jest tutaj zaszyte kilka bardzo ważnych informacji. Przede wszystkim sposób, w jaki opisujemy położenie — zwróć uwagę, że górna krawędź ekranu to ‘0’, a górna to ‘480’ — punkt 0,0 jest więc w lewym górnym rogu ekranu.
Druga kwestia to jednostki — o ile okreslamy, że nasz widok ma 640 jednostek na szerokość i 480 na wysokość, nie jest to ani rozdzielczość ani wymiary okienka. To umowne jednostki, których będziemy używali do rysowania po ekranie (OpenGL pracuje z liczbami zmiennoprzecinkowymi, więc możesz przyjąć zakres od 0 do 1, i też nie zmieni to rozdzielczości czy jakości obrazu), nie są one związane z pikselami czy jakimikolwiek innymi jednostkami w komputerze. Jedyne, co definiują, to proporcje obrazu — w tym wypadku 4:3, czyli klasycznych monitorów. Być może zechcesz zmienić to na 16:9 lub inne, to kwestia wyboru. Zastanawiasz się też pewnie dlaczego używa się takich liczb, a nie np. 1000 i 750, łatwiejszych do dzielenia przez człowieka itp? Z technicznego punktu widzenia to prawda, i o ile proporcje będą zachowane, obraz będzie wyglądał podobnie. Wybór 640x480 wiąże się z tym, że wartości te łatwo przełożyć na realne pixele w monitorze — rozdzielczość ekranu jest często wielokrotnością tych wartości. Dzięki temu grafik pracujący nad grą może w prosty sposób wygenerować ostrą linię (np. w menu), znając rozdzielczość ekranu oraz mnożnik. Byłoby to utrudnione stosując potęgi liczby 10, do których obecności się przyzwyczailiśmy.
Trzecia kwestia to optymalizacja — często generując widok gry ‘rysujemy’ także poza tym, co widzi użytkownik — jakiś element po prostu się na ekranie nie mieści, innego ma być widać tylko kawałek itp. Dzięki okresleniu co ma być widoczne OpenGL może w łatwy sposób zignorować wszystko to, czego użytkownik nie zobaczy — ponownie oszczędzając czas i pracę, nie pogarszając efektu.
Co ważne — okno, w którym realnie wyświetlamy obraz może mieć dowolne wymiary, OpenGL wyskaluje obraz tak, aby zawsze były widoczne 640x480 jednostki (w naszym przypadku).
GL11.glMatrixMode(GL11.GL_MODELVIEW);
Tutaj używamy tej samej funkcji, co na początku, tym razem mówiąc, że chcemy pracować z obiektami widoku, tym, co faktycznie będzie widoczne.
W tym miejscu mozemy już generować grafikę, przy każdym wykonaniu pętli wywołujemy jeszcze:
GL11.glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT);
Funkcja ta czyści bufory, a jej argumenty mówią o tym, co należy wyczyścić — w naszym przypadku są to bufory koloru oraz głębii, czyli całość tego, co jest dla nas widoczne.
Ostatnia funkcja, czyli
Display.update()
pobiera klatkę z bufora OpenGL i wyświetla ją w naszym oknie.
Wyświetlanie tekstu
Kolejnym naszym krokiem będzie wyświetlanie tekstu. Napotykamy tutaj na pierwszy problem — systemy operacyjne. Fonty, które mogą być dostępne w jednym systemie, niekoniecznie muszą być dostępne w innym. Do tego kwestie licencji, drobnych różnic w czcionkach o tej samej nazwie itp. Aby uniknąć tego bałaganu, wszystkimi fontami będziemy zarządzali w ramach projektu — skorzystamy w tym celu z bezpłatnych, otwartych fontów dostępnych w sieci.
Największymi zbiorami bezpłatnych fontów są Google Fonts oraz Font Library. Myśmy wybrali dwie z nich — Lato do normalnego tekstu oraz Cookie do niektórych elementów (jak np. nazwa gry). Oczywiście możesz wybrać inne, możesz wybrać więcej — ogranicza Cię tylko wyobraźnia ;)
Zwróć uwagę, że ‘font’ to nie tylko krój ale styl. Dlatego jeśli chcesz używać określonego fonta zarówno w wersji normalnej, jak i pogrubionej, musisz pobrać dwa pliki — dla systemu są one dwoma różnymi fontami.
Uwaga: z jakiegoś powodu link do repozytoriów Google w GitHub nie działa. Większość fontów z Google Fonts można znaleźć do pobrania na poprzednim, nieoficjalnym repozytorium na GitHub.
Serwis vs metody klasy bazowej
Na wstępie parę słów o tym, jak zorganizujemy nasz kod, w szczególności metody związane z wyświetlaniem tekstu. Teoretycznie mogłyby to być metody klasy AbstractView, po których dziedziczyłby każdy widok, z pewnością znajdą się osoby preferujące taką opcję. I będą miały racje, ale rację także będziemy mieli my decydując się wyciągnąć je do osobnego serwisu.
Uzasadnieniem takiego podejścia jest fakt, że dla naszej gry wyświetlanie tekstu jest jedną z oferowanych przez warstwę serwisów funkcjonalności — jest to na tyle duża część funkcji, że zasługuje na wydzielenie. Dodatkowo pozwoli to efektywniej zaimplementować zarządzanie zasobami — czcionkami itp.
Zwolennicy drugiego podejścia zwrócą zapewne uwagę, że jest to odejście od pryncypiów OOP, z czym jesteśmy skłonni się zgodzić — ostatecznie jest funkcja ‘widoku’, a więc w tej klasie powinna znaleźć się ta metoda.
Jak już wspomnieliśmy, oba podejścia są prawidłowe — IT jest dziedziną w której jedyną poprawną odpowiedzią jest ‘to zależy’ na niemal każde pytanie. Każda decyzja jest pewnego rodzaju kompromisem — w tym wypadku poświęcamy trzymanie się litery zasad programowania obiektowego na rzecz polepszenia możliwości testowania i zwiększenia wygody programowania a także kładąc większy nacisk na zasadę pojedynczej odpowiedzialności (single responsibility principle). Wybór jednej czy drugiej opcji nie jest błędem (i nigdy nie dajcie sobie wmówić, że jest inaczej…), błędem (i to sporym) byłoby zastosowanie różnych podejść do analogicznego problemu w ramach jednego systemu. O ile nasza gra jest trywialna pod kątem złożoności, pracując w prawdziwym projekcie wielokrotnie zmierzysz się z taką decyzją. Pamiętaj, że spójność podejścia jeśli chodzi o całość aplikacji (oczywiście zakładając że wszystkie rozważane drogi pozwalają na implementacje wszystkich założeń) zawsze jest ważniejsza od pryncypialnego trzymania się zasad czy konwencji. Najgorsze co możesz zrobić osobie pracującej z daną aplikacją w przyszłości (a więc być może i sobie!) to namieszać w koncepcjach używanych do tworzenia aplikacji.
Wyświetlamy tekst
Na początku zadbamy o to, żeby wyświetlić określony tekst. Definiujemy więc nowy serwis, TextService, w którym dodajemy jedną metodę:
public void renderText(String content, Float positionX, Float positionY, TextAlignment alignment, String fontName, Color color);
Użyty enum TextAlignment deklarujemy następująco:
public enum TextAlignment {
LEFT, RIGHT, CENTER
}
Zadaniem zadeklarowanej metody będzie wyświetlenie tekst w miejscu o okreslonych współrzędnych, używając czcionki o zadanej nazwie. Do obsługi fontów wykorzystamy bibliotekę slick-utils, która posiada m.in. klasę TrueTypeFont, którą wykorzystamy.
Nazwa fontu będzie odpowiadała nazwie pliku, w którym będzie on przechowywany. Aby uniknąć wielokrotnego wczytywania tych samych zasobów, raz wczytaną czcionkę będziemy przechowywali w pamięci w celu ponownego wykorzystania. W implementacji TextService tworzymy zatem logikę odpowiedzialną za wczytywanie czcionek, bazując na dokumentacji LWJGL:
public class TrueTypeFontTextService implements TextService {
private Map<String, TrueTypeFont> fontsCache = new HashMap<>();
private TrueTypeFont getFont(String fontName) {
if (!fontsCache.containsKey(fontName)) {
try {
InputStream inputStream = getClass().getClassLoader().getResourceAsStream("fonts/"+fontName+".ttf");
Font awtFont = Font.createFont(Font.TRUETYPE_FONT, inputStream);
awtFont = awtFont.deriveFont(24f); //domyślny, stały rozmiar czcionki
fontsCache.put(fontName, new TrueTypeFont(awtFont, true));
} catch (Exception e) {
log.error("Nie można wczytać fontu", e);
}
}
return fontsCache.get(fontName);
}
// ...
}
W powyższym fragmencie najpierw otwieramy plik ttf jako InputStream, na podstawie którego następnie tworzymy obiekt typu Font (jest to klasa będąca częścią Javy), po czym używamy tego obiektu do utworzenia instacji klasy TrueTypeFont (będącej częścią biblioteki Sick2D). Ponieważ pliki .ttf będą częścią pliku JAR z grą, a nie osobnymi plikami w określonym miejscu na dysku, nie jest możliwe ich odczytanie za pomocą standardowego obiektu File. Java dostarcza narzędzia pozwalające pracować z plikami ‘wewnątrz’ paczki JAR poprzez metody klasy ClassLoader — w tym wypadku metodę getResourceAsStream(…). Szersze omówienie tej metody znajduje się w oficjalnej dokumentacji.
Uwaga: Spring także dostarcza narzędzi pozwalających na pracę z zasobami — zarówno tymi wewnątrz pliku JAR, jak i np. dostępnymi z użyciem protokołu HTTP (więcej informacji znajdziesz w oficjalnej dokumentacji) — w naszym przypadku nie potrzebowaliśmy dodatkowych możliwości i użycie standardowych narzędzi Javy bezpośrednio było prostszym rozwiązaniem.
W powyższym fragmencie na stałe deklarujemy rozmiar importowanej czcionki. W przyszłości pozwolimy wybrać dowolny rozmiar, na ten moment wystarczy nam uproszczony mechanizm.
Pozostaje nam zaimplementować metodę interfejsu renderText:
public void renderText(String content, Float positionX, Float positionY, TextAlignment alignment, String fontName, Color color) {
TrueTypeFont font = getFont(fontName);
Integer textWidth = font.getWidth(content);
Float actualPositionX = positionX;
switch (alignment) {
case CENTER:
actualPositionX -= textWidth/2;
break;
case RIGHT:
actualPositionX -= textWidth;
break;
}
org.newdawn.slick.Color slickColor = new org.newdawn.slick.Color(color.getRed(),
color.getGreen(), color.getBlue(), color.getAlpha());
org.newdawn.slick.Color slickColor = new org.newdawn.slick.Color(color.getRed(),
color.getGreen(), color.getBlue(), color.getAlpha());
GL11.glEnable(GL11.GL_TEXTURE_2D);
font.drawString(actualPositionX, positionY, content, slickColor);
GL11.glDisable(GL11.GL_TEXTURE_2D);
}
Powyższa implementacja pobiera obiekt typu TrueTypeFont (z pamięci podręcznej lub wczytując czcionkę z pliku), po czym przelicza położenie uwzględniając wybrane przez nas wyrównanie tekstu do prawej, lewej lub środka (w tym celu korzystamy z pomocniczej metody getWidth, która zwraca nam szerokość danego napisu z użycie tej czcionki). Mamy więc gotową metodę pozwalającą wyświetlać tekst na ekranie.
Kod dookoła metody drawString związany jest ze sposobem, w jaki napis jest wyświetlany — aby działały nasze proste kształty razem z tekstem, musimy go otoczyć takim blokiem (więcej o tym powiemy sobie w przyszłości). Aby wyświetlony tekst był estetyczny, musimy dodać jeszcze jeden fragment do naszej klasy Game (zaraz po GL11.glMatrixMode(GL11.GL_MODELVIEW);):
GL11.glEnable(GL11.GL_BLEND);
GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA);
Pozwala on na prawidłową obsługę tzw. kanału alfa — czyli w grafice informacji o ‘kryciu’ danego elementu (lub też jego przezroczystości, jeśli odejmiemy wartość alfa od 100%).
Budujemy menu
Nasza klasa ‘MainMenu’ jest menu tylko z nazwy jak do tej pory — wyświetla statyczny tekst ;) Zajmiemy się więc zbudowaniem menu z prawdziwego zdarzenia.
Z punktu widzenia aplikacji każde menu jest takie samo — ma opcje, które po wybraniu wykonują określone czynności, wyświetlają się one w określonej kolejności, ma tło i grafikę przycisków. Jedyną ‘zmienną’ rzeczą są więc opcje — sposób wyświetlenia każdej z nich pozostaje identyczny. Wydzielmy więc funkcjonalność odpowiedzialną za renderowanie menu do klasy nadrzędnej, a w klasach menu pozostawmy jedynie elementy, które się zmieniają — opcje.
Pierwszym krokiem będzie więc implementacja klasy nadrzędnej:
public abstract class AbstractMenu implements View {
private int currentElementIndex = 0;
@Autowired
private TextService textService;
@Override
public void renderFrame() {
float initialPositionY = 50f;
float incrementY = 50f;
float positionX = 320f;
List elements = getElements();
for (int renderingElement = 0; renderingElement<elements.size(); renderingElement++) {
MenuElement element = elements.get(renderingElement);
Color color = (renderingElement==currentElementIndex) ? Color.BLUE : Color.GREEN;
textServicerenderText(element.getLabel(), positionX, initialPositionY+renderingElement*incrementY, TextAlignment.CENTER, "lato", color)
}
}
@Override
@SuppressWarnings("incomplete-switch")
public void handleKey(KeyboardKey key) {
switch (key){
case KEY_ENTER:
getElements().get(currentElementIndex).getAction().doAction();
break;
case KEY_ESCAPE:
closeMenu();
break;
case KEY_UP:
currentElementIndex = Math.floorMod(currentElementIndex-1, getElements().size());
break;
case KEY_DOWN:
currentElementIndex = Math.floorMod(currentElementIndex+1, getElements().size());
break;
}
}
protected abstract void closeMenu();
protected abstract List getElements();
}
Powyższy kod opiera się o metodę abstrakcyjną zwracającą listę elementów menu. Każdy element menu to tekst opisu wraz z akcją wykonywaną po wybraniu danego elementu. Poza listą elementów przechowujemy także ten, który jest aktualnie aktywny w postaci indeksu (gdzie 0 oznacza pierwszy od góry element menu).
Uwaga: w kodzie używamy metdy Math.floorMod(x, y), ponieważ standardowy operator modulo w Javie może zwracać liczby ujemne (dyskusję dlaczego to jest matematycznie poprawna implementacja znajdziesz np. na stackoverflow.com)
Druga metoda abstrakcyjna, closeMenu(), pozwoli obsłużyć ‘wyjście’ z menu (naciśnięcie klawisza ESC).
Kod obsługuje strzałki w górę oraz w dół, a także klawisz enter do zatwierdzania wyboru i ESC do wycofywania się w wybranego menu. Aby kod zadziałał, konieczne jest rozszerzenie naszego enuma o dodatkowe wartości (oczywiście bazując na wartościach stałych z LWJGL):
public enum KeyboardKey {
KEY_UNKNOWN(-1),
KEY_ESCAPE(1),
KEY_ENTER(28),
KEY_UP(200),
KEY_DOWN(208);
//...
}
dodatkowo konieczne będą dodatkowe klasy i interfejsy do reprezentacji elementów menu:
public interface MenuAction {
public void doAction();
}
public class MenuElement {
private String label;
private MenuAction action;
//... gettery i settery ...
}
To implementacja bardzo prosta — zakłada, że wszystkie elementy menu zmieszczą się na jednym ekranie, a jedynym ozdobnikiem jest zmiana koloru. To jednak tylko wstęp i w przyszłości zrobimy z niego perełkę ;)
Nie pozostaje nam nic innego jak zaimplementować naszą klasę MainMenu jako dziedziczącą po AbstractMenu — póki co dodaj do niej dowolne elementy, które mogą nie mieć żadnej przypisanej akcji.
Kod źródłowy
Kody źródłowe są dostępne w serwisie GitHub — użyj przycisków po prawej aby pobrać lub przejrzeć kod do tego modułu. Jeśli masz wątpliwości, jak posługiwać się Git’em, instrukcje i linki znajdziesz w naszym wpisie na temat Git’a.