Моя первая попытка поиграться с OpenGL на Haskell

28 августа 2013

На днях я разгребал свой список статей для отложенного прочтения. Помимо прочего, в нем был найден хабрапост Графика через OpenGL на Haskell, который пробудил во мне былой интерес к работе с трехмерной графикой на правильном языке. А что может остановить программиста, которому что-то дико интересно и хочется в этом разобраться?

Введение

OpenGL (Open Graphics Library) — это спецификация API для работы с двумерной и трехмерной графикой. Тут важно понимать разницу между описанием интерфейса и его реализацией для конкретного языка программирования под конкретную платформу. Несмотря на то, что программы, использующие OpenGL, часто пишутся на Си или C++, OpenGL не привязан к этим двум языкам программирования. Существуют реализации и биндинги OpenGL для Java, C#, Perl, Python и, само собой разумеется, Haskell. В отличие от DirectX, OpenGL доступен как в Windows, так и в Linux, FreeBSD, Mac OS, iOS, а также Android. Кроме того, на OpenGL можно писать игры для PS3.

GLUT (OpenGL Utility Toolkit) представляет собой библиотеку утилит для приложений, использующих OpenGL. GLUT позволяет рисовать окна, отслеживать нажатия клавиш и движения мыши, а также предоставляет функции для рисования таких графических примитивов, как куб, сфера или конус. Из аналогичных библиотек следует отметить GLFW. Правда, я пока не особо понимаю, в чем заключаются ее существенное отличие от GLUT.

Когда я начинал писать этот пост, то думал привести описание всех встречающихся функций и типов, однако, как оказалось, это описание займет очень много места. Если вас интересуют такие детали, рекомендую воспользоваться ghci:

$ cabal-dev ghci
ghci> :m + Graphics.Rendering.OpenGL
ghci> :m + Graphics.UI.GLUT
ghci> :t clear
clear :: [ClearBuffer] -> IO ()
ghci> :i ClearBuffer
data ClearBuffer
  = ColorBuffer | AccumBuffer | StencilBuffer | DepthBuffer
    -- Defined in `Graphics.Rendering.OpenGL.GL.Framebuffer'

Документацию к функциям проще всего найти через Hoogle. Также можно попытать счастье, открыв документацию по OpenGL или GLUT на Hackage. Здесь же мы будем использовать OpenGL, «войдя в слияние разумов» с GHC.

Перейдем же, наконец, к коду!

Черное окно, красное окно

Рассмотрим простейшее OpenGL-приложение на Haskell, рисующее черное окно.

import Graphics.UI.GLUT
import Graphics.Rendering.OpenGL

main = do
  getArgsAndInitialize
  createWindow "Black Window"
  displayCallback $= display
  mainLoop

display = do
  clear [ColorBuffer]
  flush

Все действие происходит внутри монады IO. Функция getArgsAndInitialize инициализирует GLUT, после чего возвращает имя программы и ее аргументы. Впрочем, в данном случае мы их игнорируем. Функция createWindow создает новое окно с заданным заголовком. Далее мы устанавливаем колбэк, отвечающий за прорисовку изображения в окне и входим в цикл обработки событий GLUT’а. Указанные нами колбэки будут вызываться из этого цикла.

Функция clear очищает изображение в окне. Вызов функции flush приводит к принудительной перерисовке изображения. Команды, выполняемые перед flush, могут буферезироваться. Вызов flush очищает буферы команд, тем самым гарантируя, что указанные ранее команды будут выполнены. Их выполнение может занять произвольно большое время. Гарантируется только то, что это время конечно.

Если перед вызовом clear добавить строчку:

clearColor $= Color4 1 0 0 1

Окно будет раскрашено в красный цвет. Как несложно догадаться, здесь мы указываем цвет, используемый для очистки. Цвет задается в формате RGBA. Помимо типа Color4 a также существует тип Color3 a, позволяющий задавать цвет в формате RGB. Эти два типа обычно параметризуются GLfloat’ом или GLdouble’ом, которые представляют собой синонимы типов CFloat и CDouble из Foreign.C.Types.

Красный треугольник

Следующая программа рисует красный треугольник на черном фоне.

import Graphics.Rendering.OpenGL
import Graphics.UI.GLUT

main = do
  _ <- getArgsAndInitialize
  _ <- createWindow "Red Triangle"
  displayCallback $= display
  mainLoop

display = do
  clear [ColorBuffer]
  currentColor $= Color4 1 0 0 1
  renderPrimitive Triangles $ do
    vertex $ Vertex3 (0    :: GLfloat)   0.5  0
    vertex $ Vertex3 (0.5  :: GLfloat) (-0.5) 0
    vertex $ Vertex3 (-0.5 :: GLfloat) (-0.5) 0
  flush

За рисование различных примитивов отвечает функция renderPrimitive.

Черное окно, красное окно и красный треугольник

Первый ее аргумент имеет тип PrimitiveMode. Он определяет, какой именно примитив мы хотим нарисовать. В данном случае мы рисуем треугольники, а вернее — один треугольник. Всего существует десять примитивов, описание которых вы найдете в туториале по OpenGL на Haskell Wiki.

Второй аргумент — это функция с типом IO (). Внутри этой функции задаются координаты точек (вершины), по которым рисуется примитив. Для представления координат используется тип Vertex3 a. Он оборачивается в монаду IO при помощи функции vertex с типом a -> IO ().

«Прыгающий» треугольник

Давайте заставим треугольник двигаться!

import Graphics.Rendering.OpenGL
import Graphics.UI.GLUT
import Data.IORef
import Data.Time.Clock.POSIX

main = do
  _ <- getArgsAndInitialize
  _ <- createWindow "Jumping Triangle"
  tstamp <- getTimestamp
  st <- newIORef (0.0, 0.5, tstamp)
  displayCallback $= display st
  idleCallback $= Just (idle st)
  mainLoop

display st = do
  (dy, _, _) <- get st
  clear [ColorBuffer]
  currentColor $= Color4 1 0 0 1
  renderPrimitive Triangles $ do
    vertex $ Vertex3 (0    :: GLdouble) ( 0.5 + dy) 0
    vertex $ Vertex3 (0.5  :: GLdouble) (-0.5 + dy) 0
    vertex $ Vertex3 (-0.5 :: GLdouble) (-0.5 + dy) 0
  flush

idle st = do
  (dy, delta, prevTStamp) <- get st
  tstamp <- getTimestamp
  let dt = tstamp - prevTStamp
      dy' = dy + delta * dt
      delta' = if abs dy' <= 0.5 then delta else -delta
  st $=! (dy', delta', tstamp)
  postRedisplay Nothing

getTimestamp :: IO GLdouble
getTimestamp = do
  now <- getPOSIXTime
  return $ fromRational $ toRational now

У программы появилось состояние, представляемое тройкой из текущего смещения треугольника по оси Y, скорости движения треугольника и времени последнего перемещения треугольника. Состояние хранится в IORef — контейнере с семантикой ссылки на изменяемую переменную.

Функция display практически не изменилась, она просто стала прибавлять к Y-координатам точек текущее смещение треугольника. Само изменение состояния происходит в функции idle, которую мы прописали в idleCallback. Заданный idleCallback вызывается GLUT’ом в случае, когда у него кончаются события для обработки. При вызове idle мы (1) определяем dt, разность между текущим временем и временем последнего вызова idle, (2) вычисляем новое смещение по формуле dy' = dy + delta * dt и (3) если треугольник смещен достаточно сильно, умножаем скорость его движения на -1.

После изменения состояния происходит вызов postRedisplay. Этим мы говорим, что что-то изменилось и картинку нужно перерисовать.

Прыгающий красный треугольник

Теперь треугольник плавно перемещается от верхней границы окна к нижней и обратно.

Перемещаем треугольник стрелками «влево» и «вправо»

Нам хотелось бы иметь возможность как-то взаимодействовать с нашей программой. Сказано — сделано.

import Graphics.Rendering.OpenGL
import Graphics.UI.GLUT
import Data.IORef
import Data.Time.Clock.POSIX

main = do
  _ <- getArgsAndInitialize
  _ <- createWindow "Moving Triangle"
  tstamp <- getTimestamp
  st <- newIORef (0.0, 0.0, 0.5, tstamp)
  keyboardMouseCallback $= Just (keyboardMouse st)
  displayCallback $= display st
  idleCallback $= Just (idle st)
  mainLoop

display st = do
  (dx, dy, _, _) <- get st
  clear [ColorBuffer]
  renderPrimitive Triangles $ do
    currentColor $= Color4 1 0 0 1
    vertex $ Vertex3 (dx + 0   :: GLdouble) (dy + 0.5) 0
    currentColor $= Color4 0 1 0 1
    vertex $ Vertex3 (dx + 0.5 :: GLdouble) (dy - 0.5) 0
    currentColor $= Color4 0 0 1 1
    vertex $ Vertex3 (dx - 0.5 :: GLdouble) (dy - 0.5) 0
  flush

idle st = do
  (dx, dy, delta, prevTStamp) <- get st
  tstamp <- getTimestamp
  let dt = tstamp - prevTStamp
      dy' = dy + delta * dt
      delta' = if abs dy' <= 0.5 then delta else -delta
  st $=! (dx, dy', delta', tstamp)
  postRedisplay Nothing

keyboardMouse st key keyState _ {-modifiers-} _ {- pos -} =
  keyboardAct st key keyState

keyboardAct st (SpecialKey KeyLeft) Down = do
  (dx, dy, delta, tstamp) <- get st
  let dx' = dx - 0.1
  st $=! (dx', dy, delta, tstamp)

keyboardAct st (SpecialKey KeyRight) Down = do
  (dx, dy, delta, tstamp) <- get st
  let dx' = dx + 0.1
  st $=! (dx', dy, delta, tstamp)

keyboardAct _ _ _ =
  return ()

getTimestamp :: IO GLdouble
getTimestamp = do
  now <- getPOSIXTime
  return $ fromRational $ toRational now

В состояние программы было добавлено смещение по оси X. Кроме того, мы прописали функцию keyboardMouse в keyboardMouseCallback. В итоге функция будет вызываться при любом нажатии кнопки на клавиатуре или мыши. При нажатии стрелок «влево» и «вправо» происходит изменение смещения по оси X. Теперь треугольник не только прыгает вверх-вниз, но и по нашему велению перемещается влево-вправо.

Радужный треугольник, который можно двигать

Также на этот раз для вершин треугольника были указаны различные цвета. В результате треугольник стал такой весь из себя радужный. Правда круто?

Вращающаяся пирамида с разноцветными гранями

Сколько можно жить в этом унылом двумерном мире? Разве OpenGL — это не про трехмерные игрушки и все такое? Нарисуем же что-нибудь объемное!

import Graphics.Rendering.OpenGL
import Graphics.UI.GLUT
import Data.IORef
import Data.Time.Clock.POSIX

data State = State {
               deltaX      :: GLdouble
             , deltaY      :: GLdouble
             , moveSpeed   :: GLdouble
             , angle       :: GLdouble
             , rotateSpeed :: GLdouble
             , tstamp      :: GLdouble
             }

main = do
  _ <- getArgsAndInitialize
  initialDisplayMode $= [WithDepthBuffer]
  _ <- createWindow "Rotating Pyramid"
  now <- getTimestamp
  state <- newIORef State { deltaX = 0, deltaY = 0, moveSpeed = 0.5
                          , angle = 0.0, rotateSpeed = 0.2
                          , tstamp = now
                          }
  depthFunc $= Just Less
  displayCallback $= display state
  idleCallback $= Just (idle state)
  keyboardMouseCallback $= Just (keyboardMouse state)
  mainLoop

display state = do
  st <- get state
  clear [ColorBuffer, DepthBuffer]
  let rot  = rotateXZ (angle st)
      move = moveXY (deltaX st) (deltaY st)
      mr   = move.rot
      p1   = mr ( 0.58,-0.29,-0.00)
      p2   = mr (-0.29,-0.29,-0.50)
      p3   = mr (-0.29,-0.29, 0.50)
      p4   = mr ( 0.00, 0.58, 0.00)

  drawTriangle (Color4 1 0 0 1) p1 p2 p4
  drawTriangle (Color4 0 1 0 1) p1 p3 p4
  drawTriangle (Color4 0 0 1 1) p2 p3 p4
  drawTriangle (Color4 1 0 1 1) p1 p2 p3
  flush

idle state = do
  st <- get state
  now <- getTimestamp
  let dt  = now - tstamp st
      ms  = moveSpeed st
      dy' = deltaY st + ms * dt
      an  = angle st  + rotateSpeed st * 2 * pi * dt
      an' = if an > 2*pi then an - 2*pi else an
      ms' = if abs dy' <= 0.5 then ms else -ms
  state $=! st { deltaY = dy', moveSpeed = ms'
               , angle = an', tstamp = now
               }
  postRedisplay Nothing

rotateXZ an (x, y, z) =
  let x' = (x * cos an) - (z * sin an)
      z' = (x * sin an) + (z * cos an)
   in (x', y, z')

moveXY dx dy (x, y, z) =
  (x + dx, y + dy, z)

drawTriangle clr p1 p2 p3 = do
  let (p1x, p1y, p1z) = p1
      (p2x, p2y, p2z) = p2
      (p3x, p3y, p3z) = p3
  currentColor $= clr
  renderPrimitive Triangles $ do
    vertex $ Vertex3 p1x p1y p1z
    vertex $ Vertex3 p2x p2y p2z
    vertex $ Vertex3 p3x p3y p3z

keyboardMouse st key keyState _ {- modifiers -} _ {- pos -} =
  keyboardAct st key keyState

keyboardAct state (SpecialKey KeyLeft) Down = do
  st <- get state
  state $=! st { deltaX = deltaX st - 0.1 }

keyboardAct state (SpecialKey KeyRight) Down = do
  st <- get state
  state $=! st { deltaX = deltaX st + 0.1 }

keyboardAct _ _ _ =
  return ()

getTimestamp :: IO GLdouble
getTimestamp = do
  now <- getPOSIXTime
  return $ fromRational $ toRational now

Эта программа делает все то же, что и предыдущая, только вместо радужного треугольника здесь рисуется правильная треугольная пирамида с разноцветными гранями. При этом основание вращается вокруг оси симметрии пирамиды, проходящей через центр этого основания. Чтобы нарисовать правильную треугольную пирамиду, нужно нарисовать четыре ее грани, представляющие собой треугольники. Поскольку треугольники мы рисовать уже умеем, вроде, все должно быть просто. Но есть нюанс.

Вращающаяся разноцветная пирамида на OpenGL и Haskell

Во-первых, строкой initialDisplayMode $= [WithDepthBuffer] мы говорим, что нам понадобится буфер глубины. Буфер глубины хранит значение глубины для каждого пикселя. Во-вторых, depthFunc $= Just Less говорит OpenGL отображать в окне цвета пикселей, лежащих на меньшей глубине. В-третьих, мы изменили вызов функции clear на clear [ColorBuffer, DepthBuffer]. Совершенно очевидно, что перед перерисовкой сцены буфер глубины должен очищаться.

Заключение

Дополнительные материалы в контексте Haskell:

Материалы по OpenGL вообще:

  • Книга OpenGL SuperBible считается очень хорошим учебником по OpenGL (переводилась на русский);
  • Помимо SuperBible также советуют OpenGL Programming Guide, известную под кодовом именем RedBook (тоже переводилась на русский);
  • Довольно интересно выглядит wiki-книга OpenGL Programming — в ней есть примеры рисования трехмерных графиков и создания порталов;
  • Еще часто рекомендуют OpenGL Samples Pack;

Исходники к заметке вы найдете в этом архиве. Для их компиляции используйте утилиту cabal.

У меня все еще много вопросов. Как работать с текстурами, анимацией, тенями и отражениям? Как управлять камерой? Как нарисовать туман, огонь, дождь, молнию или солнце? Как наложить на все это звуки? Тем не менее, знаний, описанных в этом посте, вполне достаточно для написания своих змеек, тетрисов, сборщиков кубика-рубика, эквалайзеров и многих других вещей. А это уже немало!

Дополнение: Учимся работать с текстурами в Haskell’евом OpenGL

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.