Scala i OpenGL – Rysowanie kształtów

Scala i OpenGL – Rysowanie kształtów

To co czyni ten temat interesującym jest fakt, że na ekranie narysujemy niby zwyczajny kwadrat i ten kwadrat otwiera drogę do stworzenia świata ograniczonego jedynie Twoją wyobraźnią… I nie jest to żart! Narzędzia potrzebne do wyświetlenia trójkąta lub kwadratu na ekranie to te same lub bardzo podobne narzędzia, które później odpowiadać będą za oświetlenie, tekstury, efekty 3D i wiele wiele więcej.

We wcześniejszych tutorialach stworzyliśmy okno, w którym wyświetlać możemy nasze przyszłe obiekty oraz napisaliśmy prosty silnik by ograniczyć FPSy, upłynnić i synchronizować renderowanie. Dzisiaj zajmiemy się rysowaniem.

W naszym projekcie pojawiło się kilka nowych plików i jeden nowy folder resources. W tym folderze umieszczać będziemy pliki z shaderami czyli mini programami, które uruchamiają się na jednostce graficznej. Pozostałe pliki odpowiedzialne są za kompilacje i linkowanie shaderów oraz załadowanie i wyświetlenie kształtów.

Wyświetlanie obiektów 3D

Zanim przejdziemy do kawałków kodu prześledźmy drogę od naszego programu do ekranu komputera. W OpenGL nawet gdy budujemy obiekty 2D to współrzędne zapisujemy w trzech wymiarach. OpenGL musi więc przetłumaczyć współrzędne 3D na piksele.

Za przekształcenie 3D do 2D odpowiada graphical pipeline. Pozwolę sobie nie tłumaczyć takich wyrażeń na język polski, nie ma to najmniejszego sensu, ponieważ „graficzny rurociąg” nikomu nic by nie mówił .

Wspomiany pipeline przedstawiłem na img. 1.

Pierwszy etap przetwarzania danych odbywa się w vertex shader. W tym miejscu dostęp mamy pojedynczo do każdego wierzchołka (vertex’a).

Następnie wierzchołki łączone są w prymitywne kształty. Warto zapamiętać, że podstawowym i najprostszym kształtem w grafice jest trójkąt.

Kolejnym miejscem gdzie możemy manipulować i przetwarzać dane jest geometry shader. Tutaj dostęp mamy do wszystkich wierzchołków prymitywnego kształtu.

Następnie następuje rasteryzacja, czyli mapowanie kształtu na odpowiadające im piksele. Jest to uproszczenie opisu matematycznego do możliwości wyświetlania naszego ekranu.

OpenGL pipeline
img 1. Graphical pipeline
Opracowanie własne
na podstawie: learnopengl book

Kolejnym ważnym krokiem jest fragment shader, który wylicza kolor dla każdego piksela po kolei. Można domyślić się, że skoro tutaj wyliczamy kolor, to tutaj będziemy również sprawdzać czy światło pada na dany fragment, czy znajduje się w cieniu itd.

Ostatnim krokiem jest alpha test, depth test i blending. Główny cel to sprawdzenie, który obiekt jest za którym obiektem, obliczenie przeźroczystości obiektu i w zależności od tego usunięcie lub wymieszanie wartości.

Warto zaznaczyć, że wpływać możemy jedynie na procesy po lewej stronie grafu, czyli te, które mają shader w swojej nazwie. Za ustalenie jak mają dziać się owe wyliczenia odpowiadają nasze mini programy, które nazywamy właśnie shaderami.

VAO i VBO

Znamy drogę od pojedynczego wierzchołka w vertex shader aż po grafikę na naszym ekranie. Nie wiemy jeszcze jednak skąd vertex shader ma informacje o wierzchołkach i jak te dane przekazać. Za to odpowiadają właśnie VAO oraz VBO.

Zacznijmy od rozwinięcia skrótów. VAO czyli Vertex Array Object, który możemy wyobrazić sobie jako listę, wewnątrz której znajduje się jeden lub więcej VBO (Vertex Buffer Object). VBO natomiast to również lista, tyle że prostych danych, np. liczb zmiennoprzecinkowych. Do każdego obiektu, który chcemy renderować przypisać musimy VAO, które zawierać będzie VBO z informacjami takimi jak pozycje wierzchołków i (później) wektor normalny do powierzchni czy wskaźniki kolejności łączenia wierzchołków (indices).

VAO VBO OpenGL
img 2. Wizualizacja VAO i VBO.

Powyższy schemat przedstawia VAO, które zawiera dwa VBO. Kolorami oznaczone są pary danych, to znaczy, że dla każdego wierzchołka zostanie przekazane po jednej parze danych.

Weźmy za przykład rysowanie kształtu zbudowanego z trójkątów. Przekazujemy VAO zawierające dwa vertex buffer objects, VBO 1 zawierać będzie pozycje wierzchołków a VBO 2 np. pozycje tekstury. Wierzchołek opisywany jest trzema współrzędnymi więc VBO 1 parowane jest po 3 – jeden kwadrat to jedna informacja (w tym przypadku współrzędna), trzy kwadraty jednego koloru reprezentują grupę współrzędnych X Y oraz Z. Tekstura natomiast to obrazek 2D, więc współrzędne tekstury odpowiadające wierzchołkowi parowane są po dwa (współrzędne X i Y). To tylko luźny przykład – nie musisz się w niego wgłębiać, ponieważ o teksturach opowiem innym razem.

Kompilowanie shaderów

Przechodzimy teraz do kodu i od razu rzucimy się na głęboką wodę bo zaczynamy od jednej z najbardziej obszernych klas, które dodamy dzisiaj do projektu a jest nią klasa ShaderManager. Za co odpowiada owy menadżer? Odpowiada za skompilowanie shaderów, sprawdzenie kompatybilności pomiędzy nimi i podłączenie ich do nowej instancji programu. „Program” w tym znaczeniu nie oznacza naszego programu, a programu w OpenGL, odpowiadającego za zarządzanie shaderami.

Nie ma na co czekać, nadchodzi ShaderManager!

import org.lwjgl.opengl.GL20._

class ShaderManager {

  private var vertexShaderId: Int = 0
  private var fragmentShaderId: Int = 0

  private final val programId = glCreateProgram();
  require(programId != 0, "Shader has not been created")

  def createVertexShader(shaderSourceCode: String) = vertexShaderId = createShader(shaderSourceCode, GL_VERTEX_SHADER)

  def createFragmentShader(shaderSourceCode: String) = fragmentShaderId = createShader(shaderSourceCode, GL_FRAGMENT_SHADER)

  def createGeometryShader(shaderSourceCode: String) = ???

  private def createShader(shaderSourceCode: String, shaderTypeCode: Int): Int = {
    val shaderId = glCreateShader(shaderTypeCode)
    assert(shaderId != 0, s"Error creating $shaderTypeCode shader")
    glShaderSource(shaderId, shaderSourceCode)
    glCompileShader(shaderId)
    assert(glGetShaderi(shaderId, GL_COMPILE_STATUS) != 0, s"Shader compiling error: ${glGetShaderInfoLog(shaderId, 1024)}")
    glAttachShader(programId, shaderId)
    shaderId
  }

  def link() = {
    glLinkProgram(programId)
    if (glGetProgrami(programId, GL_LINK_STATUS) equals 0) throw new Exception(s"Shader linking error: ${glGetProgramInfoLog(programId, 1024)}")
    if (!vertexShaderId.equals(0)) glDetachShader(programId, vertexShaderId)
    if (!fragmentShaderId.equals(0)) glDetachShader(programId, fragmentShaderId)
  }

  def bind() = {
    glUseProgram(programId)
  }

  def unbind() = {
    glUseProgram(0)
  }

  def cleanup() = {
    unbind()
    if (!programId.equals(0)) glDeleteProgram(programId)
  }

}

Jesteśmy na tym etapie znajomości, że nie będę wyjaśniać zmiennych i stałych, których nazwa mówi wszystko. Warto pamiętać jednak, że w Scali konstruktor nie jest specjalnie wydzielony i to „ciało” klasy wykonywane jest jako konstruktor. W tym przypadku zaczynamy od wykonania metody glCreateProgram i sprawdzenia czy nie zwróciła wartości 0, czyli że nowe instancja programu została pomyślnie utworzona.

Następnie zdefiniowane są metody do tworzenia trzech różnych shaderów, które różnią się od siebie przekazywanym kodem tworzonego shadera (jego typem). Jako, że nie będziemy przez najbliższy czas korzystać z geometry shader to zastosowałem oznaczenie braku implementacji. Oznacza to, że program ‚wywali się‚, gdy użyjemy owej definicji, lecz dopóki jej nie ruszamy, jesteśmy bezpieczni.

Kolejna metoda createShader opisuje proces tworzenia shadera i zwraca jego ID. Idąc po kolei: tworzymy shader, ładujemy do niego kod źródłowy, kompilujemy go i podłączamy shader po utworzonej wcześniej instancji programu. Brzmi sensownie, a więc idziemy dalej.

Następna metoda link() łączy i ładuje shadery do programu tworząc w nim instrukcje wykonywalne na poszczególnych procesorach (vertex, geometry i fragment processor). Na koniec metody odpinamy shadery metodą glDetachShader ponieważ zostały załadowane już do programu.

Kolejne metody wyjaśniają się same. Metoda bind() ustawia ten program jako „podłączony” a unbind() go odłącza, ustawiając 0 jako ID używanego programu.

VAO & VBO Loader

Druga bardzo ważna w kolejności nowa klasa VaoLoader to odpowiednik poczty, przez którą nadajemy nasze VAO i VBO do shaderów. Przejdźmy przez tą klasę step-by-step by lepiej ogarnąć co się tutaj dzieje

import org.lwjgl.opengl.GL11.GL_FLOAT
import org.lwjgl.opengl.GL15.{GL_ARRAY_BUFFER, GL_STATIC_DRAW, glBindBuffer, glBufferData, glDeleteBuffers, glGenBuffers}
import org.lwjgl.opengl.GL20.{glDisableVertexAttribArray, glEnableVertexAttribArray, glVertexAttribPointer}
import org.lwjgl.opengl.GL30.{glBindVertexArray, glDeleteVertexArrays, glGenVertexArrays}
import org.lwjgl.system.MemoryUtil
import scala.collection.mutable.Set

class VaoLoader {

    var vboIdList: Set[Int] = Set()
    val vaoId = createVAO()

    def loadToVAO(data: Array[Float], locationIndex: Int, size: Int = 3) = {
      storeDataInVBO(data, locationIndex, size)
    }

    private def storeDataInVBO(data: Array[Float], locationIndex: Int, size: Int) = {
      val vboId = glGenBuffers()
      vboIdList.add(vboId)

      val buffer = MemoryUtil.memAllocFloat(data.length)
      buffer.put(data).flip()

      glBindBuffer(GL_ARRAY_BUFFER, vboId)
      glBufferData(GL_ARRAY_BUFFER, buffer, GL_STATIC_DRAW)
      glEnableVertexAttribArray(locationIndex)
      glVertexAttribPointer(locationIndex, size, GL_FLOAT, false, 0, 0)
      if (buffer != null) MemoryUtil.memFree(buffer)
    }

    private def createVAO(): Int = {
      val vaoId = glGenVertexArrays()
      glBindVertexArray(vaoId)
      vaoId
    }

    def cleanUp() = {
      glBindBuffer(GL_ARRAY_BUFFER, 0)
      glDisableVertexAttribArray(0)
      vboIdList.map(glDeleteVertexArrays(_))
      glDeleteBuffers(vaoId)
      unbindVAO()
    }

    def unbindVAO() = {
      glBindVertexArray(0)
    }

  }

Wraz z utworzeniem klasy tworzymy Set, w którym przetrzymywać będziemy ID utworzonych VBO. Tworzymy również nowe VAO i zapisujemy jego ID.

Następnie definiujemy metodę loadToVAO, która odsyła nas do metody storeDataInVBO. W przyszłości będziemy mieć kilka definicji loadToVAO dlatego stosujemy taki pośredni zapis funkcji.

Metoda storeDataInVBO na wejściu dostaje listę danych, index, do którego umieścić zamierzamy je umieścić oraz size czyli ile danych składa się na jeden ciąg (grupę). size opisuje to o czym wcześniej wspomniałem, powołując się na przykład z wierzchołkami. W przytoczonym wcześniej przykładzie size wynosić będzie 3 dla współrzędnych wierzchołka oraz 2 dla współrzędnych tekstury.

Następnie w metodzie storeDataInVBO tworzymy nowe VBO metodą glGenBuffers() i dodajemy ją do listy przechowującej ID utworzonych VBO. Później tworzymy bufer i umieszczamy w nim dane, które przekazujemy i przystępujemy do określenia ustawień.

Najpierw przypisujemy do VBO jego target, my przekazujemy listę, więc ustawiamy GL_ARRAY_BUFFER. Kolejno tworzymy nowy storage dla danych, stała GL_STATIC_DRAW zaznacza, że nie będziemy ich modyfikować. Metodą glEnableVertexAttribArray informujemy, że w przekazanym indeksie będziemy umieszczać dane, czyli że to VBO będzie dostępne pod wskazanym indeksem w VAO. Ostatnią czynnością jest określenie co trzymamy pod wskazanym adresem. Używając metody glVertexAttribPointer określamy rozmiar grupy danych, typ danych (GL_FLOAT) oraz informujemy, że dane nie są znormalizowane.

To wszystko na tym etapie może nam namieszać, nie warto się jednak zniechęcać, ponieważ jak wspomniałem na początku dzięki temu większość pracy będziemy mieli za sobą. Będziemy wracać do tych funkcji dokładniej je analizując, gdy przyjdzie czas na dodawanie nowych funkcjonalności.

Pozostałe metody to metody pomocnicze i metoda czyszcząca stworzony przez nas bałagan.

Mesh, czyli siatka obiektu

Każdy stworzony przez nas obiekt składać się będzie z siatki wierzchołków, czyli mesh’a. Taki mesh musi zawierać listę pozycji wierzchołków, metodę określającą w jaki sposób jest renderowany i jak po jego utworzeniu posprzątać. By to wszystko ustrukturyzować stworzymy sobie trait RawMesh, który będzie służył do komponowania nowych siatek.

import engine.VaoLoader

trait RawMesh {

  val positions: Array[Float]

  def render()

  def cleanup()

  val vaoLoader = new VaoLoader()

  var vaoId = vaoLoader.vaoId

}

Zwróć uwagę, że każda klasa, którą rozszerzymy o RawMesh przy stworzeniu będzie miała już przypisany do niej VaoLoader i vaoID.

Teraz zajmijmy się stworzeniem siatki reprezentującej nasz kwadrat, do którego tak zacięcie dążymy. Stwórzmy nową klasę, która reprezentować będzie większość mesh’y, używanych w przyszłości.

import engine.ShaderManager
import org.lwjgl.opengl.GL11.{GL_TRIANGLES, glDrawArrays}
import org.lwjgl.opengl.GL30.glBindVertexArray
import scala.io.Source

class Mesh(val positions: Array[Float]) extends RawMesh {

  vaoLoader.loadToVAO(positions, 0)
  vaoId = vaoLoader.vaoId

  val shaderProgram = new ShaderManager()
  shaderProgram.createVertexShader(Source.fromResource("shaders/vertex.vs").mkString)
  shaderProgram.createFragmentShader(Source.fromResource("shaders/fragment.fs").mkString)
  shaderProgram.link

  def initRender() = {
    glBindVertexArray(vaoId)
  }

  def render() = {
    initRender()
    glDrawArrays(GL_TRIANGLES, 0, positions.size)
    endRender()
  }

  def endRender() = {
    glBindVertexArray(0)
  }

  def cleanup(): Unit = {
    vaoLoader.cleanUp()
  }

}

Rozszerzamy klasę Mesh o nasz trait RawMesh i ładujemy do VAO nowy VBO zawierający positions pod indeksem 0. Mam nadzieję, że wiesz o który fragment mi chodzi, przed chwilą omawialiśmy VaoLoader .

Następnie wykorzystujemy nasz ShaderManager i tworzymy vertex oraz fragment shader, ładując do nich kody z plików, które za moment stworzymy. Funkcja render() inicjuje render, czyli przypisuje VAO do naszego obiektu, rysuje przekazane wierzchołki układając je w trójkąty a na koniec uwalnia przypisane VAO. I to wszystko, teoretycznie teraz gdy utworzymy nowy Mesh z dowolnymi pozycjami ułożonymi w trójkąty, możemy narysować dowolny obiekt!

Zanim uruchomimy program musimy zrobić dwie rzeczy. Pierwsza to napisać nasze shadery wykorzystując GLSL (OpenGL Shading Language) a druga to przekazać mesh do renderowania.

Vertex i fragment shader

Na początku wpisu pokazałem schemat projektu, który opisuje gdzie znajdować powinny się stworzone shadery. Teraz będziemy tworzyć (lub jeśli je stworzyłeś to uzupełniać) pliki shaderów.

Zacznijmy od pliku vertex.vs

#version 400

layout (location=0) in vec3 position;

void main()
{
    gl_Position = vec4(position, 1.0);
}

Ok, wygląda prosto. Po tych dziesiątkach linijek kodu, przez które wcześniej się przeciskaliśmy to wydaje się banalne. Jeśli znasz C lub C++ to wydaje się nawet dość znajome. Dzisiaj pisać będziemy najbardziej podstawowe shadery jakie można spotkać.

Jeśli przyzwyczaiłeś się do pisania w Scali to ostrzegam: GLSL wymaga średników.

Wyrażeniem #version 400 określamy wersję OpenGL z której korzystamy, jeśli pojawi Ci się błąd o nieobsługiwanej wersji OpenGL spróbuj zejść do #version 330. Listę pozostałych wersji znaleźć można pod tym linkiem.

layout (location=0) in vec3 position;

Ta linia określa to co ustaliliśmy wcześniej, a mianowicie, że w location o indeksie 0 czeka na nas wektor trójelementowy i nazwać go chcemy position. W ten sposób pozyskujemy VBO z indeksu 0 VAO, w którym umieściliśmy pozycje wierzchołków. Jako, że jesteśmy w vertex shader, mamy dostęp kolejno tylko do jednego wierzchołka. Stąd jest to vec3 (wektor 3-elementowy) a nie lista wektorów.

Następnie podobnie jak w wielu językach mamy funkcję main, która uruchamia się jako pierwsza podczas pracy shadera. W tej metodzie przypisujemy do zmiennej gl_Position wektor cztero-elementowy z pozycją tego wierzchołka na naszym ekranie. Zmienna gl_Position domyślnie jest w vertex shader, więc jej nie definiujemy – wiemy, że jest to zmienna OpenGL po jej przedrostku gl_.

Sekunda… Dlaczego pozycję określamy jako wektor CZTERO-elementowy? Tak wymaga tego specyfikacja OpenGL i to nie bez powodu, czwarty element to nie jest czwarta współrzędna w czwartym wymiarze a parametr w. Będziemy z niego korzystać w pewnym momencie i wtedy, wyjaśnię obszerniej o co z nim chodzi. Tak więc współrzędne X Y oraz Z przepisujemy z position a w ustawiamy jako 1.0.

Fragment shader jest jeszcze prostszy niż vertex shader, ponieważ definiujemy tutaj jedynie kolor

#version 400

out vec4 outColor;

void main()
{
    outColor = vec4(0.0, 1.0, 0.0, 1.0);
}

Zmienna outColor również przyjmuje wektor cztero-elementowy i opisuje on kolory R, G, B oraz alpha. Tą zmienną musimy już zadeklarować ponieważ fragment shader oczekuje na wyjściu koloru jako vec4. Jako, że ta zmienna „opuszcza” shader to podczas definicji dodajemy przedrostek out. Ja ustawiłem kolor zielony (R:0, G:1, B:0), ale by przetestować kod, możesz pozmieniać te wartości według uznania.

Ostatni krok, renderowanie!

Przejdźmy do naszej klasy Renderer i stwórzmy w nim instancję klasy Mesh!

class Renderer() {

  var triangleMesh: Option[Mesh] = None

  def init(): Unit = {
    val vertices = ??? 
    triangleMesh = Some(new Mesh(vertices))
  }

  def render(window: Window) = {
    clear()
    handleResize(window)
    
    triangleMesh.map(_.shaderProgram.bind)
    triangleMesh.map(_.render())
  }

//Tutaj bez zmian
...

Pozwoliłem sobie użyć struktury Option i map w celach demonstracyjnych jednak zauważyłem, że powszechniejszą metodą w programach z OpenGL jest przypisywanie wartości null do zmiennych. Na ten moment zostawmy jednak wersję z Option[].

Powyższy kod wydaje się na tyle prosty, że nie wymaga tłumaczenia poza zmienną vertices, którą podstępnie ukryłem. Cały myk polega na tym jakie pozycje wierzchołków musimy podać by narysować kwadrat. By je określić trzeba wiedzieć, że OpenGL współrzędne na ekranie opisuje od -1 do 1 na każdej z osi.

img 3. Wierzchołki prymitywnego kwadratu w OpenGL

Pominąłem oś Z na ten moment, więc możemy założyć, że wynosi ona 0 dla każdego wierzchołka. Jak widać, by stworzyć kwadrat musimy stworzyć dwa trójkąty, zapiszmy te współrzędne w kolejności budowania trójkąta w zmiennej vertices.

    val vertices = Array[Float](
       0.5f,  0.5f, 0.0f, //(x, y, z) 
      -0.5f, -0.5f, 0.0f, 
      -0.5f,  0.5f, 0.0f,
       0.5f,  0.5f, 0.0f, 
      -0.5f, -0.5f, 0.0f, 
       0.5f, -0.5f, 0.0f
    )

Teraz możemy skompilować program i przyjrzeć się czemuś co powinno wyglądać jak kwadrat a wygląda jak prostokąt!

Dzieje się tak ponieważ nasze okno nie ma proporcji 1:1 więc oś X jest dłuższa niż oś Y rozciągając nasz „kwadrat”. Jak sprawić by obiekty zachowywały swoje kształty pomimo rozciągania okna? Jak rysować obiekty nie powtarzając współrzędnych wierzchołków? O tym następnym razem!

Gratulacje! Dotrwałeś do końca (albo to czytasz, bo za daleko zascrollowałeś)! Za pomocą stworzonych dzisiaj narzędzi będziemy mogli tworzyć obiekty 3D!

Jak za każdym razem cały kod można znaleźć na moim repozytorium Scala-OpenGL-Tutorial.

Dodaj komentarz

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

Back to top