Scala i OpenGL – Tutorial

Scala i OpenGL – Tutorial

Scala ma wiele zastosowań, m.in. aplikacje internetowe, machine learning, big data, obliczenia rozproszone itd itd. Popularnym tematem nie jest jednak tworzenie gier czy ogólniej – grafiki 3D w Scali. Zastanawiałem się dlaczego… Przecież do Javy jest cały świat bibliotek oferujących narzędzia do grafiki 3D i 2D. W Scali możemy stworzyć to samo co w Javie wystukując przy tym mniej kodu – co zwiększa czytelność i poprawia performance. Dodatkowo Scala oferuje nam paradygmaty języka funkcyjnego, których możemy użyć tam, gdzie obiektowy świat nie jest nam potrzebny.

OpenGL & LWJGL

Do tworzenia naszego wirtualnego świata użyjemy OpenGL, do którego dostęp uzyskamy dzięki bibliotece LWJGL. Słowem wyjaśnienia: OpenGL to nie jest język programowania, a dokumentacja. A dokładniej dokumentacja, która opisuje funkcje do manipulowania grafiką i obrazami. Jak wiadomo dokumentacja określa co metoda przyjmuje i co zwraca – nie zawiera szczegółów implementacji. Implementacją zajmują się producenci kart graficznych. Nad rozwijaniem i zarządzaniem standardu OpenGL czuwa Khronos Group.

Na wstępie zaznaczę, że ponadprzeciętna znajomość Scali nie jest wymagana. Jeśli znasz inne języki programowania to przyswojenie poniższego kodu powinno być intuicyjne. Warto jednak przejść chociaż jeden „Hello World” w Scali, by widzieć jak wygląda struktura plików i czym jest SBT. O samej Scali poczytać możesz tutaj.

Nowy projekt

Dyskoteka widoczna na gif 1. to cel dzisiejszego wpisu. Nie daj się zwieźć – często pierwsze tutoriale są pomijane z racji ich mało spektakularnego efektu. Szybko jednak nadchodzi moment, gdy nie wiemy co robi jakaś podstawowa funkcja, która opisana została właśnie w pierwszych tutorialach (dlatego nasza dyskoteka jest świetna!).

Okno Scala OpenGL LWJGL
gif 1. Nasz target

Zacznijmy od utworzenia nowego projektu. Ja korzystać będę z IntelIJ IDEA, każde inne IDE nada się równie dobrze. Pracuję również z Ubuntu 16.04 – jeśli jednak korzystasz z Windowsa – nie ma problemu. System na którym pracujemy nie powinien tutaj nic zmieniać. Wróćmy do naszego okna i utwórzmy nowy projekt!

File -> New -> Project -> Scala -> sbt

Następnie wybieramy nazwę swojego projektu. W moim przypadku wybrałem nazwę „ScalaOpenGL”. Nie czuj się skrępowany by mój oryginalny pomysł zastosować również u siebie.

Nowy projekt Scala OpenGL
img 1. Nowy projekt

Maszyna ruszyła! Mamy swój nowy projekt – jest jeszcze pusty, ale to nie szkodzi. Utwórzmy nowy obiekt i nazwijmy go Main, tak by łatwo było zorientować się gdzie zaczyna się proces wykonywania naszej aplikacji.

img 2. Struktura plików w projekcie

Idziemy bardzo powoli i w sumie wyszło na to, że prawie mamy tutorial „Hello World” w Scali. Zanim zaczniemy jednak uzupełniać naszego „Main’a” dodajmy biblioteki, które niezbędne są do ruszenia dalej z projektem.

Biblioteki

Czego będziemy potrzebować? Potrzebować będziemy wspomnianej biblioteki LWJGL, którą musimy dodać do projektu. Zależności deklarujemy w pliku build.sbt (widocznym na img 2.). Jako, że nie dyskryminujemy nikogo ze względu na używany system operacyjny (każdy ma prawo być szczęśliwy!) to zależności do pliku SBT dodamy tak, by projekt mógł uruchamiać się zarówno na Windowsie, Linuxie czy MacOS.

libraryDependencies ++= {
  val version = "3.2.3"
  val os = System.getProperty("os.name").toLowerCase match {
    case win if win.contains("win") => "windows"
    case linux if linux.contains("linux") => "linux"
    case mac if mac.contains("mac") => "macos"
  }

  Seq("lwjgl", "lwjgl-stb", "lwjgl-glfw", "lwjgl-opengl").flatMap(lwjglDependency => {
      Seq(
        "org.lwjgl" % lwjglDependency % version,
        "org.lwjgl" % lwjglDependency % version classifier s"natives-$os"
      )
    })
}

Jeśli nie rozumiesz co się tutaj stało, zachęcam do poczytania o SBT i Scali. Jeśli Cię to nie interesuje, luz – nie będziemy szybko wracać do pliku sbt. Ogólnie rzecz biorąc dodaliśmy wybrane 4 moduły biblioteki LWJGL do naszego projektu. Dlaczego wygląda to groźnie? Ponieważ dodatkowo umieściliśmy wykrywanie systemu operacyjnego i w zależności od tego dobieramy odpowiednią wersję biblioteki.

img 3. Refresh SBT

Pamiętaj, by po dodaniu nowych zależności w pliku .sbt załadować wprowadzone zmiany. W nowej wersji IntelIJ pojawia się ten przycisk w oknie edytora. By załadować zależności od nowa i mieć pewność, że się załadowały naciśnij przycisk z dwiema strzałkami ułożonymi w okrąg w menu sbt po prawej stronie.

Inicjalizacja

Mamy nasz plik Main.scala, mamy dodane zależności w pliku sbt. Skoro to czytasz to pewnie masz również motywację by lecieć dalej!

Zacznijmy od czegoś, co pozwoli nam uruchomić cokolwiek. Dopełnijmy Hello World w Scali. W pliku Main.scala umieśćmy funkcję main i przysłowiowy „cout” by wiedzieć, że aplikacja działa. Zawartość pliku powinna wyglądać następująco:

object Main {
  def main(args: Array[String]): Unit = {
    println("Hello World!")
  }
}

Naciskając prawy przycisk myszy na def main i wybierając run wyrzucimy na konsolę napis „Hello World!”.

Teraz skasujmy nasz println by mieć pustą funkcję main, ponieważ będziemy tworzyć nowe okno!

Do zarządzania oknem, naciskanymi klawiszami i innymi callbackami używać będziemy biblioteki GLFW. Spokojnie, nie wracamy do sbt, ta zależność znajduje się już w LWJGL.

Możemy zacząć uzupełniać naszą funkcję main od początku – pierwsze co musimy zrobić to zainicjalizować GLFW. Bez inicjalizacji nie będziemy mogli używać większości funkcji z tej biblioteki, tak więc – inicjalizujemy:

//import org.lwjgl.glfw.GLFWErrorCallback
//Poniższa linia jest opcjonalna ale bardzo zalecana, 
//dzięki niej zobaczymy błędy w konsoli programu
GLFWErrorCallback.createPrint(System.err).set

// pamiętaj by dodać wcześniej: import import org.lwjgl.glfw.GLFW._
glfwInit()

Tworzymy okno

Następnie przymierzamy się do metody utworzenia okna. Jednak przed utworzeniem nowego okna i context’u musimy dodać coś, co nazywamy window creation hints. Hinty to takie settery, które ustawiają pewne wartości przed wykonaniem metody, która z tych wartości korzysta. To oznacza, że jeśli wywołalibyśmy je po utworzeniu okna to ustawienia nie zostałyby zastosowane.

Zastosujmy więc dwa przykładowe hinty. Zablokujmy możliwość rozszerzania okna i ustalmy, by nasze okno miało ramkę. To drugie zapewne jest domyślnym ustawieniem, ale będzie dobrym przykładem do eksperymentowania z kodem.

//GL_TRUE i GL_FALSE importujemy z: import org.lwjgl.opengl.GL11._
glfwWindowHint(GLFW_DECORATED, GL_TRUE)
glfwWindowHint(GLFW_RESIZABLE, GL_FALSE)

Powyższe funkcje wydają się intuicyjne. Pierwszym argumentem jest identyfikator ustawienia, drugim przypisywana wartość. Więcej hintów i samych ustawień można znaleźć w oficjalnej dokumentacji GLFW. Teraz możemy już wywołać metodę tworzącą nowe okno.

//wartość NULL importujemy z: import org.lwjgl.system.MemoryUtil.NULL
val width: Int = 1280
val height: Int = 720
val title: String = "Scala & OpenGL"
val windowHandle = glfwCreateWindow(width, height, title, NULL, NULL)

Trzy pierwsze linie tworzą zmienne i znalazły się tutaj jedynie dla przejrzystości kodu. Spełniają swoje zadanie dobrze, ponieważ trzy pierwsze argumenty dla funkcji glfwCreateWindow zostały wyjaśnione. Są nimi szerokość, wysokość i tytuł tworzonego okna. Dwa NULL’e (zwróć uwagę, że to NULL z biblioteki LWJGL, a nie Scali) nic nam jednak nie mówią. Czwarty argument funkcji to identyfikator monitora, na którym wyświetlimy okno w opcji pełnego ekranu, czyli full screen. Jeżeli zamiast wskazania monitora przekażemy NULL, uruchamiamy okno w formie okienkowej, czyli windowed. Ostatni argument to identyfikator okna, z którym będzie współdzielił zasoby. NULL ustawia, że nie dzielimy się z nikim.

Pierwsze uruchomienie

Jeśli jesteś bardzo niecierpliwy to śmiało – kod powinien się kompilować, jednak nic ciekawego się nie wydarzy. Mignie okno i aplikacja zostanie zamknięta. Zanim stworzymy prostą pętlę, która będzie trzymała aplikację „żywą”, musimy określić context – czyli powiedzieć OpenGL, z którego okna korzystamy. Wynik funkcji glfwCreateWindow zapisaliśmy do zmiennej windowHandle, by móc odwołać się do utworzonego okna. Tak więc teraz zróbmy.

glfwMakeContextCurrent(windowHandle)
//pamiętaj o imporcie: import org.lwjgl.opengl.GL
GL.createCapabilities()

Pierwsza funkcja, jak wspomniałem wcześniej, ustawia context na nasze nowe okno. Druga metoda według dokumentacji jest kluczowa do poprawnego działania LWJGL z GLFW. LWJGL wykrywa dzięki niej context (który określaliśmy w GLFW) i umożliwia używanie połączeń z OpenGL. Nie traćmy więc czasu i zobaczmy pętlę, która sprawi, że coś zobaczymy!

while(!glfwWindowShouldClose(windowHandle)){
    glClearColor(0.1f, 0.5f, 0.8f, 0.0f)
    glClear(GL_COLOR_BUFFER_BIT)
    glfwSwapBuffers(windowHandle)
    glfwPollEvents();
}

Warunkiem działania pętli jest nieotrzymanie sygnału zamknięcia okna (domyślnie przycisk X w rogu ramki). Następnie metoda glClearColor(red, green, blue, alpha) określa kolor, który dla buffera koloru będzie ustawiany przy każdym czyszczeniu – czyli czym dla niego będzie pusty ekran. Następnie czyścimy buffer i zamieniamy przedni buffer z tylnim. Ostatnim krokiem jest nasłuchiwanie eventów, np. naciśnięcie klawisza, za co dopowiada glfwPollEvents.

Jeśli jest to dla Ciebie czarna magia to spokojnie. Nie musisz wszystkiego rozumieć na początku. O bufferach napiszę więcej innym razem, być może w oddzielnym tutorialu. Krótkim wyjaśnieniem jednak: swap buffer to zamienianie buffera, który wyświetla nasz obraz z drugim bufferem, który w tym czasie ładuje nowy obraz. W ten sposób nie „rozrywamy” ekranu próbując wyświetlić coś z buffera, do którego jednocześnie ładujemy dane.

Obecnie do glClear przekazujemy jedynie GL_COLOR_BUFFER_BIT, ponieważ nic innego poza kolorem nie wyświetlamy. W kolejnych tutorialach, gdzie wyświetlać będziemy obiekty 3D, będziemy musieli czyścić również depth buffer – GL_DEPTH_BUFFER_BIT.

Hej! Ja tu przynudzam, a nasze okno powinno już po uruchomieniu zaświecić się na niebiesko! Oczywiście jeszcze nie skończyliśmy programu, zostawiamy po sobie dużo śmieci i nie mamy obiecanej dyskoteki…

Sprzątanie

Zanim zrobimy dyskotekę skorzystajmy z tego, że idziemy po kolei i zakończmy nasz program jak należy. Po pętli zwolnijmy zajęte zasoby i tak jak nakazuje dokumentacja – zamknijmy zainicjowane biblioteki GLFW.

//import org.lwjgl.glfw.Callbacks.glfwFreeCallbacks
glfwFreeCallbacks(windowHandle)
glfwDestroyWindow(windowHandle)
glfwSetErrorCallback(null).free();
glfwTerminate()

Pierwsza linia odpowiada za zwolnienie callbacków w GLFW, druga za usunięcie okna, trzecia za usunięcie callbacku do wyświetlania błędów, a czwarta za zwolnienie zasobów i zamknięcie GLFW.

Gratulacje! Jeśli postępowałeś zgodnie z tutorialem, to właśnie uruchomiłeś (i poprawnie zamknąłeś) swój pierwszy program z OpenGL i Scalą. Co prawda OpenGL ledwie nas musnęło, ale jest to podstawa na której możemy zacząć budować znacznie więcej.

Karuzela kolorów

A teraz dyskoteka! Zróbmy tak, by na wciśniecie spacji nasz ekran zaczął szaleć. Możemy osiągnąć to dodając przed pętlą while fragment, który przypisze do klawisza spacja konkretną akcję. Przypiszemy również do klawisza escape zamykanie okna, w końcu jechanie myszką w róg ekranu i naciskanie malutkiej ikony zamknięcia to niepopularne rozwiązanie.

var offset: Float = 0.0f
glfwSetKeyCallback(windowHandle, (window, key, _, action, _) => {
    if (key.equals(GLFW_KEY_ESCAPE) && action.equals(GLFW_RELEASE)) glfwSetWindowShouldClose(window, true)
    if (key.equals(GLFW_KEY_SPACE)) offset += 0.01f
})

Metoda glfwSetKeyCallback wymaga podania okna, którego wyłapywanie klawisza dotyczy oraz funkcji, która zostanie wykonana, gdy taką akcję wykryjemy. Funkcja, której oczekuje ta metoda przyjmuje pięć argumentów: pierwsza dwa to nasze okno i naciśnięty klawisz, a czwarty to opis akcji wykonanej na klawiszu. Argumenty nr 3 i 5 pominąłem, ponieważ nic tutaj nie wnoszą.

Od teraz za każdym razem gdy nasze okno jest „zfocusowane” i naciśniemy dowolny klawisz, zostanie wykonana przekazana funkcja anonimowa. Pierwszy warunek wewnątrz tej funkcji sprawdza czy klawisz naciśnięty to escape i czy został „uwolniony” – jeśli tak to ustawia flagę dla okna, że powinno zostać ono zamknięte, a co za tym idzie -przerwiemy naszą pętlę while. Drugi sprawdza jedynie czy klawisz space jest naciśnięty i dopóki jest, będziemy zwiększać zmienną offset o 0.01.

Ostatni element to zamiana w naszej pętli funkcji glClearColor na:

glClearColor( (0.2f + offset)%1, (0.5f + offset)%1, (0.7f + offset)%1, 0.0f)

Po uruchomieniu programu, naciśnięciu i trzymaniu klawisza space powinniśmy ujrzeć przedstawioną na początku karuzelę kolorów. Teraz jesteś gotowy na rysowanie z użyciem OpenGL i napisanie swojego silnika do renderowania!

Projekt z dzisiejszego wpisu znaleźć można na moim repozytorium: Scala-OpenGL-Tutorial

TBC…?

W kolejnych działach poruszać będziemy takie tematy jak silnik do renderowania, który pilnować będzie nasze FPS’y, a zaraz potem przejdziemy do rysowania z użyciem shader’ów.

Jeśli jesteś cierpliwy to zmierzamy w kierunku stworzenia całego świata, z animacjami, wodą, odbiciami, światłami, cieniami, terenem generowanym proceduralnie i wiele wiele więcej.

Przykład, który niedługo sam będziesz mógł stworzyć!

Słowem zakończenia, jeśli przeszedłeś ten tutorial i chciałbyś zobaczyć więcej to daj znać! Tak samo jeśli gdzieś się zablokowałeś lub sposób w jaki wędrowaliśmy nie przypadł Ci do gustu.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

Back to top