niedziela, października 19, 2008

Prosty szablon wizualizacji

Wielu osobom wydaje się, że Haskell nie nadaje się do pisania czegokolwiek poza interpreterem innego języka :-) Nic bardziej mylnego! Jest to język ogólnego zastosowania, a przy tym jest on bardzo elegancki - znacznie bardziej niż ubogi w abstrakcje C czy zatłoczony składnią C++. No dobra, wystarczająco wkurzyłem fanów tych języków, czas zabrać się za konkrety ;-)

Zbudujemy dzisiaj w Haskellu prostą aplikację wykorzystującą OpenGL. Nie chcę tutaj pokazywać tajników programowania w OpenGL, od tego są inne strony. Będziemy potrzebować:


Zbudowanie ich wymaga odpowiednich plików nagłówkowych, ale to jest już mniej ciekawe zagadnienie.


  1. Nazwanie modułu

  2. Warto jest nazwać jakoś nasz program. Umieszczamy więc w pierwszej linii:
    >>> module Main where

  3. Importowanie odpowiednich bibliotek.

  4. Potrzebować będziemy OpenGL i GLFW (do robienia okienek i obsługi klawiatury):
    >>> import Graphics.Rendering.OpenGL -- lots of modules re-exported
    >>> import Graphics.UI.GLFW -- for window creation etc.

    Przydadzą się też biblioteka ułatwiająca programowanie wielowątkowe:
    >>> import Control.Concurrent
    Biblioteka do monad też się przyda:
    >>> import Control.Monad
    Z kolei ta biblioteka udostępnia funkcję printf, podobną do tej znanej z C:
    >>> import Text.Printf

  5. Inicjalizacja GLFW

  6. Początek funkcji main może wyglądać tak:
    >>> main = do
    >>>    initOk <- initialize -- [1]
    >>>    if not inikOk then error "Błąd inicjalizacji GLFW!" else return () -- [2]
    >>>     vmodes <- get videoModes -- [3]
    >>>     let bestVM = last vmodes -- [4]
    >>>     setWindowModeOk <- openWindow (videoModeToSize bestVM) [videoModeToRGBBits bestVM] FullScreen -- [5]
    >>>     if not setWindowModeOk then error "Nie udało się otworzyć okna" else return () -- [6]

    W linijce [1] próbujemy zainicjować GLFW. Funkcja ta zwraca wartość logiczną, którą sprawdzamy w [2], jeżeli nie udało się (ma ona wartość False) przerywamy działanie programu za pomocą funkcji error. Po pobraniu informacji o dostępnych rozdzielczościach ([3]) i wybraniu potencjalnie najlepszej z nich ([4]) robimy znów podobną rzecz: próbujemy otworzyć okno w tej rozdzielczości ([5]), jeżeli nam się nie powiedzie przerywamy działanie programu ([6]). Korzystamy przy tym z funkcji pomocniczych, o których opowiem za chwile.

    Niby działa, ale... można to napisać lepiej.

    >>> main = do
    >>>     tryTo initialize (error "Błąd inicjalizacji GLFW!")
    >>>     bestVM <- last `fmap` get videoModes
    >>>     tryTo (openWindow (videoModeToSize bestVM) [videoModeToRGBBits bestVM] FullScreen)
    >>>       (error "Nie udało się otworzyć okna")

    Co się zmieniło? Dwie rzeczy:
    - wprowadziliśmy funkcję tryTo
    - użyliśmy funkcji fmap
    Definicja funkcji tryTo jest prosta:

    >>> tryTo :: IO Bool -> IO () -> IO ()
    >>> tryTo doAction actionIfFailed = do
    >>>     b <- doAction
    >>>     if not b then actionIfFailed else return ()

    Przy pomocy funkcji when i rezygnując z niepotrzebnego cukru syntaktycznego można tą definicje istotnie skrócić:
    >>> tryTo doAction actionIfFailed = doAction >>= \b -> when (not b) actionIfFailed
    Nieco nieczytelnie można to zapisać jako:
    >>> tryTo doAction actionIfFailed = doAction >>= (flip unless) actionIfFailed
    Ale to już jest lekka przesada :-)

    Pozostaje jeszcze sprawa funkcji fmap. Pozwala ona nakarmienie funkcji argumentem pochodzącym z monady (tak na prawdę jej definicja jest nieco ogólniejsza, ale to nieistotne). Możliwa jej definicja w kontekście monad jest taka:
    >>> fmap pureFunc monadAction = monadAction >>= \ v -> return (pureFunc v)

    Dzięki tym dwóm funkcjom kod został skrócony i stał się bardziej przejrzysty. Przejdźmy więc nieco dalej.

  7. Funkcje pomocnicze

  8. W kodzie powyżej wykorzystałem dwie funkcje o następujących definicjach:
    videoModeToSize vm = Size (fromIntegral $ videoWidth vm) (fromIntegral $ videoHeight vm)
    videoModeToRGBBits vm = DisplayRGBBits (videoRedBits vm) (videoGreenBits vm) (videoBlueBits vm)
    Są to swego rodzaju funkcje "rzutowania", czy też "ekstrakcji": z trybu wideo (vm :: VideoMode) otrzymujemy za pomocą pierwszej funkcji rozmiar okna, zaś za pomocą drugiej - liczbę bitów poszczególnych kolorów. Nie jest to parametr, który koniecznie musimy podać, jednak pozwala on wymusić pewne zachowanie.

  9. Dalsze ustawianie środowiska

  10. Skoro mamy już okno, warto by nadać mu tytuł
    >>> windowTitle $= "My first Haskell + OpenGL app"
    Co się wydażyło w tej linijce?
    Skorzystaliśmy z funkcji $= do ustawienia parametru windowTitle. Jest to funkcja implementowana przez typy należące do klasy HasSetter. W większości popularnych języków programowania (C++, C#, Delphi, Ruby...) mamy możliwość tworzenia parametrów, choć różnie się nazywa w tych językach te mechanizmy. Zasadniczo chodzi o pewien parametr klasy, który można ustawić na pewną wartość. W momencie wykonania przypisania nie jest jednak wprost zapisywana w pamięci pewna wartość, lecz wykonywana jest odpowiednia metoda klasy z argumentem będącym wartością "przypisywaną" parametrowi. Jest to mechanizm wygodny i użyteczny, o czym świadczy chociażby fakt szerokiego wsparcia w językach. W Haskellu możemy ten mechanizm zaimplementować sami i - co więcej - jest to bardzo proste. Po szczegóły odsyłam do źródeł.

    W podobny sposób ustawimy callbacki (brrr co za słowo) do reagowania na zdarzenia środowiska:

    >>> keyCallback $= myKeyCallback
    >>> charCallback $= myCharCallback

    Gdzie myKeyCallback i myCharCallback to funkcje:

    >>> -- wywoływany przy naciśnięcu jakiegokolwiek klawisza
    >>> myKeyCallback key Press = do
    >>>    case key of
    >>>      SpecialKey ENTER -> restoreWindow
    >>>      SpecialKey ESC -> iconifyWindow
    >>>      _ -> return ()
    >>> myKeyCallback _ _ _ = return ()

    >>> -- wywoływany przy naciśnięcia klawisza, któremu odpowiada jakiś znak
    >>> myCharCallback chr st = putStrLn (printf "Znak '%c' został %s." chr (if st == Press then "naciśnięty" else "puszczony"))

    myKeyCallback reaguje na naciśnięcie entera i klawisza escape: jedno z nich chowa okno, drugi je pokazuje. Z kolei myCharCallback wypisuje informacje o tym, że ktoś nacisnął lub puścił jakiś klawisz któremu odpowiada pewien znak. Można zawrzeć funkcjonalność myCharCallback w myKeyCallback, ale nie byłoby to wygodne.

    Zmienimy jeszcze rozmiar punktu:
    >>> pointSize $= 4.0

    Nieco inaczej wygląda ustawienie dwóch innych parametrów:

    >>> enableSpecial KeyRepeat -- powtarzanie klawiszy
    >>> enableSpecial MouseCursor -- widoczny kursor myszy
    >>> --enableSpecial AutoPollEvent -- automatyczne pobieranie nowych eventów [*]

    Linia [*] jest wykomentowana, gdyż nie chcemy tak na prawdę tej funkcjonalności od GLFW. Jeżeli jest ona włączona to eventy pobierane są automatycznie przy wywołaniu funkcji swapBuffers. Ponieważ czasami wyświetlamy niewiele klatek na sekundę, nasza aplikacja zachowywałaby się w takich momentach nieprzyjemnie. Zamiast tego uruchomimy w tym celu oddzielny wątek:

    >>> forkIO (forever pollEvents)

    Zostanie on automatycznie zabity przy końcu działania programu.

    Chcielibyśmy też ustawić jakieś opcje widoku. Nie będziemy wymyślni, wystarczy nam ortogonalny widok "2D":

    >>> let Size sizeX sizeY = videoModeToSize bestVM
    >>> ortho2D 0 (fromIntegral sizeX) 0 (fromIntegral sizeY)

    Użycie funckji fromIntegral jest w paru miejscach konieczne, by zmienić typ z jednego całkowitoliczbowego na jakiś inny typ liczbowy (np. Int -> Double, GLint -> Int).

  11. Główna pętla renderingu

  12. Jesteśmy teraz gotowi na wywołanie pętli rysowania. Robi się to tak:
    >>> mainLoop
    ;-)

    Oczywiście musimy podać definicję mainLoop:
    >>> mainLoop :: IO ()
    >>> mainLoop = do
    >>>     (Position px py) <- get mousePos
    >>>     clear [ColorBuffer]
    >>>     let gray = Color3 0.5 0.5 0.5 :: Color3 Float
    >>>     let myPoint = (Vertex2 (fromIntegral px) (fromIntegral py)) :: Vertex2 Float
    >>>     renderPrimitive Points $ color gray >> vertex myPoint
    >>>     swapBuffers
    >>>     mainLoop

    Rysujemy więc w aktualnym położeniu kursora duży (ustawiliśmy to przed chwilą) szary punkt.

  13. A jak stąd wyjść?

  14. Wszystko wygląda fajnie, ale czasami chcemy wyjść z programu, prawda? Aby to zrealizować wykorzystamy pewien rodzaj zmiennych - MVar - których obsługę znajdziemy w module Control.Concurrent (a dokładniej tutaj).

    Obiekt typu MVar Int jest "pudełkiem" które może być albo puste, albo przechowywać obiekt typu Int. Stworzymy więc nową, pustą zmienną MVar która gdy zostanie zapełniona będzie sygnalizować konieczność zakończenia aplikacji. Proste, prawda?

    Do funkcji main dopisujemy przed wywołaniem mainLoop jedną linijkę:
    >>> quitMVar <- newEmptyMVar :: IO (MVar ())
    Teraz quitMVar będzie pudełkiem w którym może znaleść się wartość typu () - jest to typ o jednym elemencie, który także wygląda jak () - jest to krotka o zerze elementów:
    ()
    -- nie ma krotek jednoelementowych: (1)
    (1,2)
    (1,2,3)
    (1,2,3,4)
    (1,2,3,4,5)

    Nie potrzebujemy zatrudniać tutaj typu Bool czy też Int, więc nie robimy tego.

    quitMVar przekazujemy teraz do zmodyfikowanych wersji myKeyCallback i mainLoop:

    >>> keyCallback $= myKeyCallback quitMVar

    >>> mainLoop quitMVar

    Definicje te wyglądają tak:

    >>> -- wywoływany przy naciśnięcu jakiegokolwiek klawisza
    >>> myKeyCallback quitter key Press = do
    >>>    case key of
    >>>      SpecialKey BACKSPACE -> iconifyWindow
    >>>      SpecialKey ENTER -> restoreWindow
    >>>      SpecialKey ESC -> putMVar quitter () -- umieszczamy wartość w "pudełku"
    >>>      _ -> return ()
    >>> myKeyCallback _ _ _ = return ()

    >>> mainLoop :: MVar () -> IO ()
    >>> mainLoop qMV = do
    >>>      (Position px py) <- get mousePos
    >>>      clear [ColorBuffer]
    >>>      let gray = Color3 0.5 0.5 0.5 :: Color3 Float
    >>>      let myPoint = (Vertex2 (fromIntegral px) (fromIntegral py)) :: Vertex2 Float
    >>>      renderPrimitive Points $ color gray >> vertex myPoint
    >>>      swapBuffers
    >>>      tryTo (not `fmap` isEmptyMVar qMV) (mainLoop qMV) -- jeżeli qMV jest pusta, to kontynuujemy

  15. Sprzątamy po sobie...


  16. Kiedy wyszliśmy już z pętli renderingu nie zostało wiele do zrobienia. Zamykamy okno i idziemy spać:

    >>> closeWindow


Choć post ten wyszedł na znacznie dłuższy niż początkowo zamierzałem, sam kod jest zwarty i niewielki. Można go obejrzeć tutaj i tutaj. Kod źródłowy dostępny do ściągnięcia tutaj.

Mam nadzieję, że post wyszedł choć trochę ciekawie :-)

Zadania dla czytelników:
Znaleźć błąd w programie - łatwo go zauważyć po jego uruchomieniu

5 komentarzy:

  1. C jest ubogi w abstrakcyjne pojęcia. Mimo to, ten kod można przetłumaczyć 1-1 do C. Z drugiej strony można pisać w C "obiektowo" (np. GTK) :-) więc jakaś namiastka abstrakcji jest do osiągnięcia w tym języku.

    IMO nie ma za dużo haskella w tym haskellu. Jeżeli w dzisiejszych czasach tak się pisze w haskellu, to mogę zacząć w tym coś tworzyć. Tylko jaki jest z tego zysk?

    To co pokazywał TWI jednak się znacznie różniło ;-P

    OdpowiedzUsuń
  2. Istotnie, niewiele w tym kodzie jest kodu specyficznego dla Haskella. Wystarczy spojrzeć na typy funkcji:
    ghc -ddump-types tutorialOpenGl
    TYPE SIGNATURES
    :Main.main :: IO ()
    main :: IO ()
    mainLoop :: MVar () -> IO ()
    myCharCallback :: forall t.
    (PrintfType (t -> [Char] -> String)) =>
    t -> KeyButtonState -> IO ()
    myKeyCallback :: MVar () -> Key -> KeyButtonState -> IO ()
    tryTo :: IO Bool -> IO () -> IO ()
    videoModeToRGBBits :: VideoMode -> DisplayBits
    videoModeToSize :: VideoMode -> Size

    Wszystkie istotne są funkcjami "nieczystymi", w monadzie IO. Jest to jednak nieuchronna konsekwencja specyfiki zadania: otwieranie okna, ustawianie zmiennych na karcie graficznej itp. Z ciekawych rzeczy, specyficznych dla Haskella wymienić można:
    - łatwe utworzenie nowego wątku o ciekawej semantyce :forkIO (forever pollEvent)
    - zmienne MVar, ułatwiające synchronizacje
    - definicja tryTo jako funkcji definiującej przepływ w programie, coś czego w C nie zrobisz

    OdpowiedzUsuń
  3. Anonimowy4:44 PM

    to nie jest nieuchronna konsekwencja specyfiki zadania, to jest wina kiepskosci bindingow openglowych wynikajaca z braku sily roboczej do napisania czegos porzadnego - specyficznej dla opengla monady, w ktorej mozesz sobie updatowac zmienne na karcie graficznej ale nie mozesz czytac plikow ani innych brzydkich rzeczy. a nawet gola funkcja w IO zwracajaca boola sukcesu smierdzi nietradycjonalnoscia - przewaznie sie uzywa wyjatkow i wrapuje to w bracketa, a zamiast pisac wlasne tryTo sie uzywa MonadPlusowe guard (twi sie ucieszy).

    a w ogole to podziwiam ze chce ci sie takie banaly pisywac i to po polsku, przeczyta to 10 osob. kto umie napisac to umie i tak, kto nie umie to go to wcale nie zacheci i nie nauczy. Lepiej wez sie za ciekawsze rzeczy (wiem ze mozesz) i pisz po angielsku to chociaz na proggita cie dons wrzuci.

    Paczesiowa

    OdpowiedzUsuń
  4. Używanie wyjątków jako standardowego narzędzia przepływu funkcji w programie śmierdzi mi goto. Z resztą po co tworzyć nowy typ wyjątków skoro jest dokładnie jedno miejsce w którym może on wystąpić? Ok, mogę to opakować w monadę Error, tylko... to nie pasuje do tego przykładu. Miał być możliwie kompaktowy. Co do guarda: przy niepowodzeniu dostajemy mzero, co nie skutkuje szczególnie przyjemnym komunikatem błędu.

    Czy to co napisałem to banały? Zależy dla kogo. Dla mnie napisanie samego posta zajęło znacznie więcej czasu niż sam program - ale ja znam Haskella. Grupą docelową tych postów są przyszli polscy informatycy którzy zainteresują się (mam nadzieję) Haskellem i którzy nie umieją się jeszcze w tym języku biegle poruszać. Pokazując przykładowy działający program mam nadzieję zachęcić ich do tego języka.

    Być może warto by pisać po angielsku. Rozważałem to i nie wykluczam, że niektóre posty będę pisać w tym języku. Z mojego doświadczenia wynika jednak, że całkiem sporo osób nie radzi sobie biegle z angielskim i np. przeczytanie opisu Forsythe'a było dla nich sporym wysiłkiem językowym (pomijając poziom abstrakcji dokumentu). Skoro sam Haskell jest trudny do opanowania, to po co utrudniać im naukę języka pisząc po angielsku?

    OdpowiedzUsuń
  5. Anonimowy11:53 PM

    toz wlasnie error/undefined jest najwiekszym goto jakie jest w haskellu! nie trzeba tworzyc nowego typu wyjatkow, mozna uzyc stringow - i tak nic z tym nie robisz i nikomu to w niczym nie pomoze wiec czy to czy mzero to i tak raczej nic sie nie naprawi na podstawie komunikatu. a co do oprawiania w ErrorT to raczej mi chodzilo o to co ci od bilbioteki powinni zrobic a nie ty.

    co do jezyka to czy chcemy zalania sceny ludzmi niepotrafiacymi sie posluzyc podstawowym angielskim? ja sie jezyka nauczylem na niemieckich stronach pornograficznych i starczylo to do czytania Olega i trollowania na #haskell .

    jesli chodzi o banaly to kazdy ma swoj pomysl na przyciaganie nowych, ale piszac po angielsku moze nie przyciagniesz kilku polakow ale moze sie uda przyciagnac kilkunastu znajacych angielski swiatowcow.

    Paczesiowa

    OdpowiedzUsuń