Инициализация программируемого графического конвейера OpenGL
Для Haskell есть замечательные биндинги к OpenGL, а также биндинги к популярным “клиентским” вспомогательным библиотекам вроде GLUT, GLFW, SDL и прочим.
Мощь и изящество языка Haskell вместе с разнообразием фундаментальных подходов к композиции программ (монады, Arrow
, FRP и т.д.) делают его чуть-ли не идеальным инструментом в написании корректных графических программ вроде симуляций или игр.
Задачи⌗
Попробуем разобраться с минимальными действиями, необходимыми для создания простенькой программки, использующей систему OpenGL современным образом: а именно, выполняя вычисления на высокопроизводительном GPU и управляя программируемым графическим конвейером с помощью вершинных, фрагментных и других шейдеров. В данной статье мы напишем простенькую программку вращения кубика с клавиатуры, попутно разбираясь в Haskell-специфике следующих деталей:
- Создание окна и инициализация графического контекста GL
- Обработка внешних событий (например, нажатие клавиш) клиента коллбэками GLUT
- Подготовка Vertex Attributes – потока данных для загрузки в GPU
- Создание Vertex Array Object (VAO) и Vertex Buffer Object (VBO), буферизация данных и определение правил их интерпретации аппаратурой
- Загрузка, компиляция и линковка GLSL-файлов программы для шейдерного процессора GPU
- Манипулирование uniform-переменными GLSL-микропрограммы из Haskell-кода
- Обсчёт преобразования вращения нашего кубика не в Haskell-программе, а в GPU (в вершинном шейдере)
Теория⌗
В общем виде наша простая программка будет состоять из следующих частей:
- Сопряжение OpenGL с оконной подсистемой (GLUT)
- Вершинные атрибуты (позиции вершин куба)
- Передача вершинных атрибутов аппаратуре, получение VAO
- Вершинный и фрагментный шейдеры
Очень общая идея такова:
- Создать окно
- Закачать данные в VBO, установить шейдеры, получить некоторый дескриптор
- аппаратура работает только с треугольниками, поэтому куб задан 12 треугольниками (36
Vertex3
)
- аппаратура работает только с треугольниками, поэтому куб задан 12 треугольниками (36
- Использовать этот дескриптор в коллбэках GLUT (при перерисовке окна, при клавиатуре)
- в дескрипторе: 3-вектор углов вращения,
Program
(шейдерная программа),VertexArrayObject
(VAO) - а также количество индексов VAO (у нас это - количество вершин куба), нужное для команды рендеринга
- нажатиями на стрелки клавиатуры меняются углы (X-Y компоненты); коллбэк модифицирует значение
IORef
- перед командой рендеринга
drawArrays
(в коллбэке дисплея) вектор с углами считываются по ссылке и подаётся какuniform
-значение перед запуском шейдерной программы
- в дескрипторе: 3-вектор углов вращения,
- После полной инициализации мы входим в
mainLoop
GLUT и продолжаем в коллбэках - Фрагментный шейдер выводит один цвет для всех фрагментов
- Вершинный шейдер использует этот самый вектор с углами и вычисляет матрицу поворота для вершины, применяя её (умножая на неё вектор позиции)
Практика⌗
Чтобы не пихать всё в один файл мы разобъём код на несколько Haskell-модулей. Код на языке GLSL поместим в файлы и будем загружать их во время выполнения.
Организация кода⌗
Не претендуя на особую смекалку я разделил функционал таким образом:
$ ls -1 src/
GLPipeline.hs
GLUTAbstraction.hs
LoadShaders.hs
Main.hs
Types.hs
Utils.hs
Vertices.hs
fragment.glsl
vertex.glsl
Модуль Vertices
содержит позиции вершин куба. Модуль LoadShaders
взят из официальных примеров GLUT и содержит удобный интерфейс загрузки/компиляции/линковки шейдеров (не будем останавливаться на этом подробнее). Коллбэки и код создания окна находятся в GLUTAbstraction
, настройка GL-контекста, копирование данных, сборка шейдеров и прочие операции – в GLPipeline
. Как результат последних мы получаем объект типа Descriptor
, который и используем с GLUT далее.
Types⌗
Наш дескриптор:
module Types where
import Graphics.UI.GLUT
import Data.IORef
data Descriptor = Descriptor (IORef (Vector3 Float)) Program VertexArrayObject NumArrayIndices
Main⌗
Код начала программы прост. Подготавливаем всё, что связано с GLUT, и входим в event-loop.
module Main where
import GLUTAbstraction
main :: IO ()
main = do
glutPrepare
glutLoop
GLUTAbstraction⌗
Инициализация GLUT представлена в функции glutPrepare
.
module GLUTAbstraction where
import Graphics.UI.GLUT
import Data.IORef
import Types
import GLPipeline
glutPrepare :: IO ()
glutPrepare = do
(progName, _args) <- getArgsAndInitialize
initialDisplayMode $= [ RGBAMode ]
initialWindowSize $= Size 512 512
initialContextVersion $= (4, 3)
initialContextProfile $= [ CoreProfile ]
createWindow progName
descriptor <- pipelineSetup
displayCallback $= display descriptor
specialCallback $= Just (specKeyDown descriptor)
glutLoop :: IO ()
glutLoop = mainLoop
Удобная функция getArgsAndInitialize
не только инициализирует подсистему OpenGL, но и парсит стандартные аргументы GLUT-программы. Установкой минимальных требований версии и initialContextProfile
в CoreProfile
мы сообщаем GLUT о том, что будем использовать новую систему OpenGL и теряем
совместимость со старым железом. Кстати, биндинги подключают пакет StateVar
, поэтому мы можем использовать удобные $=
и $~
для установки и модификации глобальных переменных (все IORef
тоже реализуют интерфейс StateVar
). Далее, получаем дескриптор и устанавливаем коллбэки.
Код коллбэка перерисовщика окна выглядит так. Здесь и происходит рендеринг куба в drawArrays
:
display :: Descriptor -> DisplayCallback
display (Descriptor avref prog vao numvs) = do
-- очищаем цветовой буфер перед его использованием
clear [ ColorBuffer ]
-- считываем текущий вектор с углами
(Vector3 ax ay az) <- get avref
-- находим переменную uniform vec3 rotAngles в микропрограмме
rotAngles <- get $ uniformLocation prog "rotAngles"
-- устанавливаем её в значение вектора с углами
uniform rotAngles $= Vector3 ax ay az
-- можем включить wireframe-режим
polygonMode $= (Line, Line)
-- рисуем (с начала VAO) сложную картинку одной командой OpenGL!
drawArrays Triangles 0 numvs
-- сброс цветового буфера в фреймбуфер (т.е. в контекст в окне)
flush
Код обработки нажатия клавишей-стрелок довольно прост, потому что GLUT содержит специальный коллбэк для “special” клавиш:
specKeyDown :: Descriptor -> SpecialCallback
specKeyDown (Descriptor avref _ _ _) KeyLeft _ = avref $~ (\(Vector3 ax ay az) -> Vector3 ax (ay-0.1) az) >> p
specKeyDown (Descriptor avref _ _ _) KeyRight _ = avref $~ (\(Vector3 ax ay az) -> Vector3 ax (ay+0.1) az) >> p
specKeyDown (Descriptor avref _ _ _) KeyUp _ = avref $~ (\(Vector3 ax ay az) -> Vector3 (ax+0.1) ay az) >> p
specKeyDown (Descriptor avref _ _ _) KeyDown _ = avref $~ (\(Vector3 ax ay az) -> Vector3 (ax-0.1) ay az) >> p
specKeyDown _ _ _ = return ()
p = postRedisplay Nothing
В GLUT для удобства определены некоторые синонимы, поэтому фактических параметра три.
type SpecialCallback = SpecialKey -> Position -> IO ()
После эндоморфизма вектора с углами не забываем запросить у GLUT перерисовку окна (после чего в скором времени попадём в соответствующий коллбэк).
Vertices⌗
Здесь мы триангулированием задаём куб с шириной 1.
module Vertices where
import Graphics.UI.GLUT
import Foreign.Storable(sizeOf)
cube :: [Vertex3 GLfloat]
cube =
[
Vertex3 (-0.5) (-0.5) (-0.5)
, Vertex3 (-0.5) (-0.5) 0.5
, Vertex3 (-0.5) 0.5 0.5
, Vertex3 0.5 0.5 (-0.5)
, Vertex3 (-0.5) (-0.5) (-0.5)
, Vertex3 (-0.5) 0.5 (-0.5)
, Vertex3 0.5 (-0.5) 0.5
, Vertex3 (-0.5) (-0.5) (-0.5)
, Vertex3 0.5 (-0.5) (-0.5)
, Vertex3 0.5 0.5 (-0.5)
, Vertex3 0.5 (-0.5) (-0.5)
, Vertex3 (-0.5) (-0.5) (-0.5)
, Vertex3 (-0.5) (-0.5) (-0.5)
, Vertex3 (-0.5) 0.5 0.5
, Vertex3 (-0.5) 0.5 (-0.5)
, Vertex3 0.5 (-0.5) 0.5
, Vertex3 (-0.5) (-0.5) 0.5
, Vertex3 (-0.5) (-0.5) (-0.5)
, Vertex3 (-0.5) 0.5 0.5
, Vertex3 (-0.5) (-0.5) 0.5
, Vertex3 0.5 (-0.5) 0.5
, Vertex3 0.5 0.5 0.5
, Vertex3 0.5 (-0.5) (-0.5)
, Vertex3 0.5 0.5 (-0.5)
, Vertex3 0.5 (-0.5) (-0.5)
, Vertex3 0.5 0.5 0.5
, Vertex3 0.5 (-0.5) 0.5
, Vertex3 0.5 0.5 0.5
, Vertex3 0.5 0.5 (-0.5)
, Vertex3 (-0.5) 0.5 (-0.5)
, Vertex3 0.5 0.5 0.5
, Vertex3 (-0.5) 0.5 (-0.5)
, Vertex3 (-0.5) 0.5 0.5
, Vertex3 0.5 0.5 0.5
, Vertex3 (-0.5) 0.5 0.5
, Vertex3 0.5 (-0.5) 0.5
]
Будем использовать список cube
в следующем модуле.
GLPipeline⌗
В данном модуле представлена всего одна функция. Она настраивает пайплайн и как результат возвращает наш дескриптор с VAO и другими данными:
module GLPipeline where
import Foreign.Marshal.Array(withArray)
import Graphics.UI.GLUT
import Data.IORef
import Types
import Utils
import LoadShaders
import Vertices
pipelineSetup :: IO Descriptor
pipelineSetup = do
-- создание и биндинг VAO
vao <- genObjectName
bindVertexArrayObject $= Just vao
-- создание и биндинг VBO
arrayBuffer <- genObjectName
bindBuffer ArrayBuffer $= Just arrayBuffer
-- собственно буферизация (знаем габариты cube)
withArray cube $ \ptr -> do
let size = fromIntegral (numVertices cube * vertexSize cube)
bufferData ArrayBuffer $= (size, ptr, StaticDraw)
-- считывание, компиляция, линковка и активация шейдерной программы
program <- loadShaders [
ShaderInfo VertexShader (FileSource "vertex.glsl"),
ShaderInfo FragmentShader (FileSource "fragment.glsl")]
currentProgram $= Just program
-- правила доступа к загруженным данным (локация 0 VAO)
vertexAttribPointer (AttribLocation 0) $=
(ToFloat,
VertexArrayDescriptor 3 Float 0 (bufferOffset 0))
-- включаем локацию
vertexAttribArray (AttribLocation 0) $= Enabled
-- создание ссылки с вектором углов вращения (изначально: нет вращения)
avref <- newIORef $ Vector3 0 0 (0 :: Float)
return $
Descriptor avref program vao (fromIntegral (numVertices cube))
Отдельного внимания заслуживет буферизация данных. Вспомогательные функции numVertices
и vertexSize
узнают габариты нашего cube
. Для bufferData
нужен указатель (Ptr a
) на передаваемые данные, поэтому мы используем функцию withArray
, которая делает такой указатель из своего аргумента и передаёт его Ptr
некоторому монадическому действию.
[a] -> (Ptr a -> IO b) -> IO b
Как минимум необходимы вершинный и фрагментный шейдеры. Их код приведён в следующем параграфе.
Haskell-биндинг vertexAttribPointer
концептуально довольно сильно отличается от исходной библиотечной функции glVertexAttribPointer, однако разобраться не так уж и сложно:
vertexAttribPointer :: AttribLocation -> StateVar (IntegerHandling, VertexArrayDescriptor a)
На “локации” (по индексу) 0 каждый компонент безликих данных (последовательности каких-то 3-векторов – только мы знаем, что это положения вершин) следует понимать так:
- целые числа понимать как float
- атрибут (в нашем случае это – позиция в пространстве) имеет 3 компонента (в списке у нас
Vector3
) - компоненты имеют тип float
- смещение в байтах между последовательными атрибутами (ещ раз: один атрибут - это трёхкомпонентный вектор, под которым мы понимем положение) равно нулю, т.е. они идут подряд в потоке
- начальное смещение в прибинденном в данный момент
ArrayBuffer
(т.е. VBO) равно нулю байтов; используем вспомогательную функциюbufferOffset
для полученияPtr Int
(?) из0
Здесь мы создаём и ссылку на вектор углов вращения, которым будем пользоваться после получения дескриптора.
Loadshaders⌗
Код данного замечательного модуля позаимствуем у профессионалов. Семантика его использования в нашей программе довольно очевидна.
Utils⌗
Вспомогательный код, не имеющий отношение к OpenGL (а потому вынесенный в отдельный модель).
module Utils where
import Foreign.Ptr
import Foreign.Storable
bufferOffset :: Integral a => a -> Ptr b
bufferOffset = plusPtr nullPtr . fromIntegral
numVertices l = length l
vertexSize l = sizeOf (head l)
Вершинный шейдер⌗
Собственно преобразование (поворот) вершины достигается умножением матрицы на вершину. Матрица, в свою очередь, рассчитывается на основании uniform-локации rotAngles
, установленной главной программой. Всё расчёты происходят в GPU.
#version 430 core
in vec4 vPosition;
uniform vec3 rotAngles;
mat4 rotationMatrix(vec3 axis, float angle)
{
axis = normalize(axis);
float s = sin(angle);
float c = cos(angle);
float oc = 1.0 - c;
return mat4
(oc*axis.x*axis.x+c,oc*axis.x*axis.y-axis.z*s,oc*axis.z*axis.x+axis.y*s,0.0,
oc*axis.x*axis.y+axis.z*s,oc*axis.y*axis.y+c,oc*axis.y*axis.z-axis.x*s,0.0,
oc*axis.z*axis.x-axis.y*s,oc*axis.y*axis.z+axis.x*s,oc*axis.z*axis.z+c,0.0,
0.0,0.0,0.0,1.0);
}
void
main()
{
mat4 trans =
rotationMatrix(vec3(1,0,0), rotAngles.x) *
rotationMatrix(vec3(0,1,0), rotAngles.y) *
rotationMatrix(vec3(0,0,1), rotAngles.z);
vec4 pos = trans * vPosition;
gl_Position = pos;
}
Фрагментный шейдер⌗
Фрагментный шейдер тривиален, поскольку мы просто генерируем один и тот же цвет каждый раз:
#version 430 core
out vec4 fColor;
void
main()
{
fColor = vec4(0.0, 0.0, 1.0, 1.0);
}
Результат⌗
Вот и всё. Стрелками влево-вправо и вверх-вниз можно плавно вращать наш кубик. При этом наша программа, фактически, ничего и не делает: почти все данные и вычисления находятся и выполняются в GPU!