Scala i OpenGL – pętla gry (game loop)

Scala i OpenGL – pętla gry (game loop)

Jeśli zaglądałeś do artykułu pierwszego to z pewnością masz już działające okno aplikacji, które odświeża się z nieznaną prędkością aktualizując kolor tła. Cały problem polega na tym, że nie znamy prędkości z jaką wykonuje się nasz program, a co gorsza zużywamy całkowicie niepotrzebnie pokłady pamięci. Nie mamy jeszcze żadnej kontroli na FPS’ami (eng. Frame Per Second) czyli klatkami na sekundę. Proste malowanie kolorów w poprzednim przykładzie wykonuje się tak często jak to tylko możliwe, jeśli masz dobry komputer to zapewne jest to więcej niż 400FPS. Przeciętny monitor odświeża się z częstotliwością od 60Hz do 120Hz więc więcej niż 60 lub 120 FPS’ów powodowałoby tak zwane „screen tearing” czyli efekt szarpania ekranu.

Co więc musimy zrobić? Stworzyć pętlę gry, która będzie kontrolowała częstotliwość renderowania obrazu i częstość uaktualniania stanu gry.

Refactor projektu

Zanim przejdziemy dalej zobaczmy co mamy do zrobienia. Jako, że na podstawie tego silnika będziemy tworzyć dalsze projekty musimy uporządkować jego strukturę. Pliki szybko będą przybywać więc stwórzmy różne packages by poukładać nasze podwórko już na starcie.

W poprzednim poście mieliśmy folder scala a w nim jeden plik Main.scala. Na obrazku img 1 widzimy jak powoli projekt się rozrasta. Mamy teraz 7 plików a każdy z nich jest odpowiedzialny za konkretną rzecz.

img 1. Nowa struktura plików

Zacznijmy od stworzenia nowego package w głównym folderze projektu scala o nazwie „game” a następnie przenieśmy do niego plik Main.scala. Następnie stwórzmy drugi package w folderze scala i nazwijmy go „engine”. W ten sposób podzielimy metody i obiekty na te, które odpowiadają za działanie silnika graficznego (engine) i te, które je implementują by stworzyć pewien świat (game). Na tym etapie możesz stworzyć puste obiekty i klasy według img 1. by później je uzupełniać. Oczywiście jak wolisz, możesz tworzyć je na bieżąco.

Game loop i Timer

Pętla i struktura gry, którą zaimplementowałem opiera się na tej przedstawionej w książce LWJGLGameDev z moimi poprawkami i zmianami. Mimo wszystko bardzo polecam to źródło, można tam znaleźć przydane tematy przeprowadzone w języku Java. Zacznijmy jednak analizę kodu!

Pierwszy na ruszt weźmy obiekt Timer ma on za zadanie dostarczyć informacje o czasie, który upłynął pomiędzy wydarzeniem X a Y. Posłuży nam do ograniczenia FPS’ów i UPS’ów – czyli Updates Per Second.

object Timer {

  var lastLoopTime = .0f
  var lastElapsedTime = .0f

  def init(): Unit = {
    lastLoopTime = time()
  }

  def time(): Float = System.nanoTime / 1000_000_000.0f

  def elapsedTime: Float = {
    val t = time()
    lastElapsedTime = t - lastLoopTime
    lastLoopTime = t
    lastElapsedTime
  }

}

Metoda elapsedTime() zwraca czas, który upłynął pomiędzy nowym a poprzednim jej wywołaniem. Jest to prosty timer, który pojawia się w dużej ilości materiałów odnośnie tworzenia pętli gier – nam na początek zdecydowanie wystarczy.

Następnie czeka na nas klasa GameLoop, która z powyższego timera korzysta.

class GameLoop(windowTitle: String, width: Int, height: Int, vSync: Boolean) {

  private val FRAME_PER_SEC = 60
  private val UPDATES_PER_SEC = 60

  private val window = new Window(windowTitle, width, height, vSync)
  private val scalaGame = new ScalaGame()

  def run(): Unit = {
    init()
    gameLoop()
  }

  def init(): Unit = {
    Timer.init()
    scalaGame.init(window)
  }

  private def gameLoop(): Unit = ???

  private def sync(): Unit = ???

  private def input(): Unit = {
    scalaGame.input()
  }

  private def update(interval: Float): Unit = {
    scalaGame.update(interval)
  }

  private def render(): Unit = {
    scalaGame.render(window)
    window.update
  }
}

Jest tutaj kilka rzeczy do omówienia, dlatego okroiłem GameLoop z implementacji funkcji by przejść je po kolei. Zacznijmy od deklaracji stałych, FRAME_PER_SEC i UPDATES_PER_SEC to górne ograniczenia klatek na sekundę i uaktualnień stanu gry na sekundę, my zastosujemy stosunek tych wartości 1:1.

Następnie tworzymy nowe okno Window (o tym za moment) oraz nową implementację gry ScalaGame, do której również za moment dojdziemy.

Następnie tworzymy metodę run(), której zadaniem jest zainicjowanie wartości i uruchomienie pętli gry. Metoda init() inicjalizuje timer (ustawia jego czas) oraz uruchamia metodę inicjalizacji klasy gry.

Pozostałe metody poza gameLoop i sync() wywołują odpowiadające ich zadaniu metody w scalaGame. Dzięki temu zmiany będziemy mogli wprowadzać w implementacji gry, nie w silniku. Przejdźmy więc do metody gameLoop.

  private def gameLoop(): Unit = {
    var elapsedTime: Float = .0f
    var accumulator: Float = 0f
    val interval = 1f / UPDATES_PER_SEC
    while (!glfwWindowShouldClose(window.windowHandle)) {
      elapsedTime = Timer.elapsedTime
      accumulator += elapsedTime
      input()
      while (accumulator >= interval) {
        update(interval)
        accumulator -= interval
      }
      render()
      if (!window.vSync) sync()
    }
     window.cleanup()
  }

Początek funkcji to deklaracje zmiennych i obliczenie interwału aktualizacji stanu gry. Następnie jest pętla, której warunek jest taki sam jak ten, który stworzyliśmy w poprzednim tutorialu. W pętli co krok dodajemy do akumulatora czas, który upłynął od poprzedniego jej wykonania. Gdy akumulator osiągnie czas odpowiadający UPS to wykonujemy metodę update(), która aktualizuje stan gry. Dzięki temu nie uruchamiamy obliczeń częściej niż jest to potrzebne. Następnie wykonujemy metodę render(). Nad tym by całość (w tym render()) nie wykonywała się częściej niż ustalony FPS czuwa poniższa linia z wyrażeniem warunkowym.

Wyrażenie if (!window.vSync) sync() pilnuje by uruchom metodę sync() jeśli nie korzystamy z vSync. sync() odpowiada za przeczekanie określonego czasu, by osiągnąć ustalony FPS. Natomiast gdy vSync jest uruchomiony to GLFW automagicznie dostosuje odpowiednią ilość FPS’ów do naszego monitora. Ostatnia linia window.cleanup() po zakończeniu pętli wykonuje czyszczenie przed zamknięciem programu.

  private def sync(): Unit = {
    val syncStep = 1f / FRAME_PER_SEC
    val endTime = Timer.lastLoopTime + syncStep
    while (Timer.time < endTime) Thread.sleep(1)
  }

Nowe okno aplikacji

Ten temat już przerabialiśmy jednak wprowadziliśmy mały refactor. Okno tworzymy teraz w nowej klasie Window, której większość zdążyliśmy już poznać.

class Window(title: String, var width: Int, var height: Int, val vSync: Boolean) {

  GLFWErrorCallback.createPrint(System.err).set
  glfwInit()

  var resized: Boolean = false

  val windowHandle = glfwCreateWindow(width, height, title, NULL, NULL)

  glfwMakeContextCurrent(windowHandle)
  GL.createCapabilities()

  glfwSetFramebufferSizeCallback(windowHandle, (_, width, height) => {
    this.width = width
    this.height = height
    resized = true
  })

  glfwSetKeyCallback(windowHandle, (window, key, _, action, _) => {
    if (key.equals(GLFW_KEY_ESCAPE) && action.equals(GLFW_RELEASE)) glfwSetWindowShouldClose(window, true)
  })

  if(vSync) glfwSwapInterval(1) else glfwSwapInterval(0)

  glClearColor(0, 0, 0, 0)

  def update() = {
    glfwSwapBuffers(windowHandle)
    glfwPollEvents();
  }

  def setClearColor(r: Float, g: Float, b: Float, a: Float) = {
    glClearColor(r, g, b, a)
  }

  def cleanup() = {
    glfwFreeCallbacks(windowHandle)
    glfwDestroyWindow(windowHandle)
    glfwSetErrorCallback(null).free();
    glfwTerminate()
  }

}

Do stworzonego okna dodaliśmy takie rzeczy jak zmienna resize, która określa, czy w danym kroku okno zostało rozszerzone. Callback, który wykona się gdy zmienimy rozmiary okna i funkcje update(), setClearColor() oraz cleanup(), które grupują metody z poprzedniego tutoriala. Wydaje mi się, że ten temat poprzednio przerobiliśmy wystarczająco i nie chciałbyś czytać o szczegółach na nowo.

Dla czytelności wyizolowałem również eventy klawiatury - teraz znajdują się w obiekcie Keyboard. Metodę czytania naciśniętych klawiszy również przerobiliśmy tydzień temu więc dodam jedynie jak wygląda zawartość tego obiektu.

object Keyboard {

  private var windowHandler: Long = 0

  def init(windowHandle: Long) = windowHandler = windowHandle

  def isKeyPressed(keyCode: Int): Boolean = glfwGetKey(windowHandler, keyCode).equals(GLFW_PRESS)
  def isKeyReleased(keyCode: Int): Boolean = glfwGetKey(windowHandler, keyCode).equals(GLFW_RELEASE)

}

Implementacja gry

W GameLoop spotkaliśmy konstruktor ScalaGame, to w tej części deklarować będziemy późniejsze obiekty, postacie, teren czy niebo. Pomyśl o tym jako miejscu, gdzie składasz wszystkie klocki razem. Na obecną chwilę ma bardzo prostą budowę:

class ScalaGame {

  private var offset = 0.0f
  private val renderer = new Renderer()

  def init(window: Window): Unit = {
    renderer.init()
    Keyboard.init(window.windowHandle)
  }

  def input(): Unit = {
    if (Keyboard.isKeyPressed(GLFW_KEY_SPACE)) offset += 0.01f
  }

  def update(interval: Float): Unit = {

  }

  def render(window: Window): Unit = {
    window.setClearColor((0.2f + offset)%1, (0.5f + offset)%1, (0.7f + offset)%1, 0.0f)
    renderer.render(window)
  }
}

To co jest tu nowego, czego jeszcze nie przerabialiśmy to jakiś dziwny Renderer. Pozostałe rzeczy to poukładane elementy z pierwszego artykułu. Na wzmiankę zasługuję pusta funkcja update(), w której później będziemy umieszczać obliczenia np. ruchu obiektów czy kamery.

Wspominany Renderer to bardzo ważny element budowanego przez nas silnika. Obecnie nie korzystamy jeszcze z shaderów ale tutaj właśnie będą one budowane a później wykorzystywane do renderowania obrazów.

class Renderer() {

  def init(): Unit = {

  }

  def render(window: Window) = {
    clear()
    handleResize(window)
  }

  private def clear(): Unit = {
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
  }

  private def handleResize(window: Window) = {
    if (window.resized) {
      glViewport(0, 0, window.width, window.height)
      window.resized = false
    }
  }

}

Jako, że cały program obecnie tylko czyści buffer i ustawia jego kolor to nic wielkiego się tutaj nie dzieje. Sprawdzamy jedynie czy podczas cyklu okno zmieniło swoje wymiary, i jeśli tak, to ustalamy jego nowy rozmiar metodą glViewport(0, 0, window.width, window.height).

To by było prawie na tyle. Został jeszcze plik Main.scala, nasz pierworodny obiekt z poprzedniego tutoriala. Jak on teraz wygląda?

object Main {

  def main(args: Array[String]): Unit = {

    val width: Int = 1280
    val height: Int = 720
    val title: String = "Scala & OpenGL"
    val vSync: Boolean = true

    val gameEngine = new GameLoop(title, width, height, vSync)
    gameEngine.run()
  }


}

Jego jedynym zadaniem jest stworzenie i odpalenie GameLoop'a. Możemy tutaj również zmieniać ustawienia, w tym vSync, o którym wspomniałem wcześniej. Polecam jednak zostawić go uruchomionym, dzięki temu mamy większą pewność, że program będzie działał poprawnie na różnych systemach.

Teraz program powinien się kompilować i robić dokładnie to samo co w pierwszym tutorialu z tą różnicą, że teraz kontrolujemy czas aktualizacji i renderowania.

Zabawę czas zacząć

W końcu przebrnęliśmy przez dwa tematy, które wymagają poświęcenia im czasu i są niezbędne by móc zacząć rysować! Z takim zarządzaniem plikami i prostym ale wydajnym silnikiem możemy stworzyć dowolny świat 3D. Kolejnym razem stworzymy prosty rysunek z wykorzystaniem GLSL i dowiemy się czym jest VAO i VBO.

Kod z dzisiejszego wpisu znaleźć można na moim repozytorium Scala-OpenGL-Tutorial.

Na tej podstawie budować można bardziej zaawansowane rzeczy!

Dodaj komentarz

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

Powrót do góry