Programación de GPU en Haskell usando GPipe - Parte 1

Posted on April 27, 2016 by rainbyte
Tags: gpipe, gpu, haskell, opengl

Nota: estas leyendo la traducción al castellano de una serie de tutoriales en ingles sobre GPipe; la versión original, escrita por Tobias Bexelius (creador de GPipe), se encuentra aqui.

Bienvenidos a la primera parte de una serie de tutoriales sobre programación de GPU en Haskell! Vamos a usar GPipe 2.1, el cual fue recientemente publicado. GPipe 2 es un API funcional basada en OpenGl 3.3, pero este tutorial no requiere conocimiento previo sobre OpenGl, asi que si sabes Haskell (lo cual es un prerequisito), y alguna vez quisiste aprender programación grafica, ahora es el momento!

Hello triangle

Comencemos con un pequeño ejemplo, el programa “Hello world!”:

{-# LANGUAGE ScopedTypeVariables, PackageImports, TypeFamilies #-}   
module Main where   
   
import Graphics.GPipe   
import qualified "GPipe-GLFW" Graphics.GPipe.Context.GLFW as GLFW  
import Control.Monad (unless)  
  
main =    
  runContextT GLFW.newContext (ContextFormatColor RGB8) $ do  
    vertexBuffer :: Buffer os (B4 Float, B3 Float) <- newBuffer 3  
    writeBuffer vertexBuffer 0 [ (V4 (-1) 1 0 1, V3 1 0 0)  
                               , (V4 0 (-1) 0 1, V3 0 1 0)  
                               , (V4 1 1 0 1,  V3 0 0 1)  
                               ]  
                        
    shader <- compileShader $ do  
      primitiveStream <- toPrimitiveStream id  
      fragmentStream <- rasterize (const (FrontAndBack, ViewPort (V2 0 0) (V2 500 500), DepthRange 0 1)) primitiveStream   
      drawContextColor (const (ContextColorOption NoBlending (V3 True True True))) fragmentStream  
      
    loop vertexBuffer shader   
    
loop vertexBuffer shader = do    
  render $ do   
    clearContextColor (V3 0 0 0)   
    vertexArray <- newVertexArray vertexBuffer  
    let primitiveArray = toPrimitiveArray TriangleList vertexArray  
    shader primitiveArray   
  swapContextBuffers  
    
  closeRequested <- GLFW.windowShouldClose   
  unless closeRequested $  
    loop vertexBuffer shader 

Como puedes ver en la lista de import, se requiere un paquete opcional: GPipe-GLFW (version 1.1 o superior). Este paquete provee la funcionalidad necesaria para crear ventanas, en las cuales GPipe puede dibujar, asi como las funciones para obtener entrada de teclado y mouse. Esta funcionalidad solia ser parte de las versiones anteriores de GPipe pero, ya que muchos querian ser capaces de elegir libremente que gestor de ventanas usar, se movio a su propio paquete. Al momento de escribir este articulo solo existen bindings para GLFW, pero seguramente apareceran otros más.

Cuando realizas import Graphics.GPipe tambien obtienes los paquetes linear y Boolean completos, ya que son utilizados constantemente en aplicaciones GPipe.

Ahora estamos listos para compilar (usa -threaded como parametro para GHC, ya que GPipe-GLFW lo requiere) y ejecutar nuestro programa, el cual nos mostrará un triangulo bastante colorido en la esquina inferior izquierda de la ventana:

Un triangulo colorido

El contexto

Lo primero que hacemos en la función main es ejecutar runContextT. Un contexto posee dos cosas: una ventana, y un espacio de objetos. La ventana es donde tus graficos renderizados se mostraran en pantalla, y el espacio de objetos es lo que va a contener todos los datos para la GPU que tu programa define, muy parecido a lo que es un proceso para los datos usados por la CPU. runContextT crea un nuevo contexto para nostros. Toma tres argumentos: una fabrica, un formato, y una acción monadica.

La fabrica es lo que le damos a GPipe asi sabe que ventana usar. Para utilizar el paquete GPipe-GLFW, que importamos previamente, pasamos GLFW.newContext como fabrica.

El formato describe que clase de imagenes vamos a estar dibujando en la ventana, por ej. cuantos canales de color va a tener y cuantos bit por color. Tambien describe si vamos a tener un depth buffer o un stencil buffer asociado a la ventana (voy a discutir que son más adelante en este tutorial, cuando detalle como dibujar). Puedes incluso crear un contexto que no posee una ventana, por ej. si quieres usar la GPU para generar imagenes y guardarlas a disco, en vez de mostrarlas en la pantalla. Ahora vamos a quedarnos con un formato de color RGB de 8 bits por cada uno de sus tres canales, sin depth buffer ni stencil buffer. El valor que describe este formato es ContextFormatColor RGB8.

El ultimo parametro para runContextT es la acción monadica en la cual todo nuestro programa ocurre. Cuando esta acción retorna, la ventana es cerrada. Esta acción monadica tiene el tipo ContextT w os f m a. Esto es un monad transformer, es decir una monada que hereda las capacidades de otra monada de tipo m. Para ContextT, m es el tipo de la monada en la cual ejecutamos runContextT. En este, y muchos otros casos, es simplemente la monada IO. Dentro de un monad transformer puedes usar la función lift para ejecutar una acción en la monada heredada.

GPipe usa algunos trucos con los tipos de datos, para asegurar que las variables que retornan sus acciones dentro del contexto, no salen de el. Este es el mismo mecanismo que usa la monada ST para asegurarse que ninguna STRef es retornada ni usada en otra invocación a runST. El truco es que runContextT usa algo llamado rank-2 type:

runContextT :: (MonadIO m, MonadAsyncException m)
            => ContextFactory c ds w 
            -> ContextFormat c ds 
            -> (forall os. ContextT w os (ContextFormat c ds) m a) 
            -> m a

Fijate que hay un modificador forall para os, local al argumento de la acción monadica ContextT. Esto hace que cualquier objeto que referencie a os este limitado a esta acción monadica.

Es posible ejecutar otro runContextT dentro de una monada ContextT, el cual va a crear una segunda ventana con su propio contexto. Ya que estos contextos poseen su propio espacio de objetos, no pueden compartir entre ellos objetos que referencien al parametro de tipo os. Esto es una limitación bastante grande y, la mayor parte de la veces que trabajes con varias ventanas, vas a querer dejarlos usar el mismo espacio de objetos. Esto se logra usando runSharedContextT. Esta acción debe ser utilizada dentro de otro ContextT, y la acción monadica que se pasa a esta función va a usar el mismo espacio de objetos que el ContextT que la rodea, pero va a tener una ventana propia.

El parametro w en el tipo ContextT es algo definido por la fabrica del contexto. Cuando usamos GLFW.newContext, w va a ser GLFWWindow. Esto es un tipo opaco, asi que no puede usarlo directamente. A pesar de esto, nos permite usar windowShouldClose y otras acciones del paquete GPipe-GLFW dentro de nuestro contexto. En nuestro programa hello world, windowShouldClose es usado para salir del loop cuando el usuario cierra la ventana, al hacer click sobre la X en la esquina superior.

Renderizado - De eso se trata realmente

Ahora que tenemos nuestro contexto, hagamos algo de renderizado. Cualquier renderizado que haga en GPipe, va a seguir esta secuencia de operaciones:

Secuencia de operaciones de GPipe

Por lo pronto, todo renderizado de GPipe va a crear, a partir de un buffer de datos, un array de vertices que serán ensamblados en un array de primitivas. Hay tres clases de primitivas: puntos, lineas, y triangulos; pero vamos a trabajar casi exclusivamente con triangulos. El array de primitivas entonces se transforma en un stream de primitivas dentro de un shader, permitiendonos aplicar transformaciones a esos vertices. Las primitivas luego son rasterizadas, es decir son cortadas en fragmentos medidos en pixels, formando un stream de fragmentos. Este stream es luego dibujado en la ventana del contexto, o en una imagen fuera de pantalla.

En la monada ContextT, comenzamos creando un buffer de datos que es almacenado en la GPU. En nuestro ejemplo hello world de más arriba, nuestro buffer es llamado vertexBuffer y tiene 3 elementos, siendo cada uno una tupla (B4 Float, B3 Float). B4 y B3 son para un buffer las “representaciones” de V4 y V3, los tipos vectoriales del paquete linear. Voy a dar más detalles sobre que son estas “representaciones” en la siguiente parte de este tutorial, pero por ahora puedes pensar a B4 como otro nombre para V4 cuando lo usamos en un Buffer. Despues de crear el buffer, escribimos tres valores dentro de él, a partir de una lista comun.

Con una función llamada render ejecutamos otra monada, convenientemente llamada… Render. En esta monada usamos nuestro Buffer para crear un VertexArray con la función newVertexArray. Viniendo de nuestro vertexBuffer, vertexArray tendrá 3 vertices, cada uno de los cuales tiene una tupla (B4 Float, B3 Float). Ahora debes preguntarte cual es la diferencia entre un VertexArray y una Buffer. Una pregunta verdaderamente razonable, pero me temo que vamos a tener que esperar hasta la siguiente parte de este tutorial para responderla, lo siento.

Ahora que tenemos un VertexArray, vamos a usarlo para crear un PrimitiveArray de triangulos, usando la función toPrimitiveArray. El argumento TriangleList, que pasamos a la función, indica que queremos formar triangulos a partir de cada tres vertices consecutivos en un vertexArray. Como solo hay tres vertices, primitiveArray va a contener un solo triangulo.

Mirando el grafico de arriba, tenemos que convertir este PrimitiveArray en un PrimitiveStream (estaras pensando, ¿otro nombre más para la misma cosa?) pero, ¿porque en el código solo vemos shader primitiveArray?

Shaders - Un pequeño acercamiento

La caja gris en el grafico de arriba es llamada Shader. Supongo que será poco sorprendente a esta altura pero, ¡tambien es una monada! La diferencia con ambas monadas, ContextT y Render, es que no podemos ejecutarla directamente, tiene que ser primero compilada. Esta compilación es distinta a la que haces cuando ejecutas ghc, cabal, stack, o cualquier acceso directo que tengas en emacs. Esta compilación ocurre durante el tiempo de ejecución del programa, y usa un compilador que provee tu controlador grafico. La compilación puede tomar varios segundos, definitivamente no es algo que quieres hacer durante cada frame en por ej. un juego creado con GPipe.

Una monada Shader es compilada mediante la función compileShader, que es ejecutada en tu monada ContextT. compileShader retornará una función que luego puedes ejecutar en una monada Render. En nuestro ejemplo de arriba, compilamos el shader en una función a la que llamamos simplemente shader. Este shader es lo que vemos ejecutarse como ultima acción en la monada Render, pasandole primitiveArray como argumento.

Demos ahora una mirada al Shader en nuestro ejemplo. La primera acción que ejecutamos es toPrimitiveStream. Esto cargará un PrimitiveArray en algo llamado PrimitiveStream. El PrimitiveArray a cargar es seleccionado mediante la función pasada como argumento a toPrimitiveStream, en este caso id. Una monada Shader es casi como una monada Reader, ya que es cerrada sobre un entorno. Pero a diferencia de la monada Reader, no hay una acción ask por la cual puedes recuperar el entorno. En vez de esto, otras acciones, como toPrimitiveStream, van a tomar una función que extrae valores de este entorno. Cada valor del entorno no es definido hasta que el shader es ejecutado, es decir ni siquiera cuando es compilado. ¿Recuedas que pasamos primitiveArray como argumento a nuestra función shader compilada? Ese es el entorno que usamos en nuestro programa. Ya que la función pasada a toPrimitiveStream quiere extraer un PrimitiveArray del entorno, y nuestro entorno es un PrimitiveArray, simplemente usamos id.

Un PrimitiveStream es tambien una secuencia de primitivas, pero vive dentro del shader y por lo tanto podriamos mapear funciones sobre él, las cuales correran sobre la GPU. PrimitiveStream implementa el typeclass Functor, y fmap f primitiveStream retornará un nuevo PrimitiveStream que es resultado de aplicar la función f a cada vertice de cada primitiva en primitiveStream. Mapear funciones sobre streams con fmap en shaders es muchas veces más rapido que hacer la misma clase de operación en listas ordinarias, ya que estamos usando la GPU en vez del CPU. En nuestro ejemplo “Hello world”, no estamos realmente haciendo nada con las primitivas en nuestro primitiveStream antes de pasarla a la función rasterize. Pero antes de entrar en ese tema, dejame mencionar cual es el tipo de datos inferido de primitiveStream:

primitiveStream :: PrimitiveStream Triangles (V4 VFloat, V3 VFloat)

Como puedes ver, los tipos B4 y B3 que teniamos en nuestro buffer (y nuestros vertex array y primitive array), fueron transformados nuevamente en V4 y V3, pero ¡los Float dentro de ellos fueron aparentemente transformados en VFloat! VFloat es en realidad un sinonimo para el tipo S V Float, el cual representa un Float desplazado a un stream de vertices en la GPU, es decir ya no es más un Float ordinario que puedes usar en cualquier función, solo puedes hacer con el cosas que la GPU soporta. Voy a discutir este tipo de datos con más detalle cuando revisemos los shaders con mayor profundidad en una parte posterior de este tutorial.

Rasterización

Incluso aunque nunca mapeemos ninguna función a nuestro primitiveStream para ejecutarla en la GPU, ni tampoco al fragmentShader que estamos por crear, todavia hay una operación que siempre hacemos en un shader la cual aprovecha el paralelismo masivo de la GPU: rasterización.

Rasterización es el proceso de mapear una primitiva, por ej. un triangulo, a una grilla y generar fragmentos medidos en pixels. Los vertices de las primitivas de entrada son usados de dos maneras: primero, todos deben proveer una posición del vertice, asi el rasterizador sabe cuantos fragmentos generar; y segundo, proveer valores que seran interpolados linealmente entre todos los vertices de la primitiva, para crear valores unicos en cada fragmento generado.

El primer argumento para rasterize, es una función que extrae tres parametros del entorno del shader: que lado de la primitiva rasterizar, las posición y el tamaño del view port, y el rango de profundidad (depth range) del fragmento. En nuestro ejemplo, sabemos todos los parametros de antemano y no necesitamos obtenerlos del entorno del shader, por eso es que usamos la función const. Los parametros que proveemos a rasterize le dicen que debe rasterizar ambos lados de cada triangulo, que el view port tiene (0,0) como coordenada inferior izquierda y tanto altura como ancho de 500 pixels, y finalmente que el rango de profundidad es [0,1]. Más sobre esto en un momento.

Las posiciones de los vertices son coordenadas 3D en un espacio de vista canonico (canonical view space). Durante la rasterización, estos van a ser transformados en el view port en espacio de pantalla en pixels, donde la posición (-1,-1,z) en el espacio de vista canonico va a ser mapeado a la esquina inferior izquierda del view port (en nuestro caso (0,0)), y (1,1,z) va a ser mapeado a la esquina superior derecha (en nuestro caso (500,500)). Para ser más precisos, el fragmento en la esquina inferior izquierda en nuestro caso va a tener realmente la coordenada de pixel (0.5,0.5), y el fragmento superior derecho que generaremos tendrá coordenada (499.5,499.5).

Todo fragmento tambien tiene un valor de profundidad en el rango [0,1]. En la rasterización nosotros especificamos, con el parametro DepthRange, como mapear la coordenada canonica z a este rango. Una coordenada z con valor -1 será mapeada al primer parametro de DepthRange, y una coordenada z con valor 1 será mapeada al segundo parametro de DepthRange. En nuestro ejemplo, nosotros mapeamos las coordenadas z en el espacio de vista canonico de rango [0,1] al rango de profundidad [0,1]. La convencion usada por Linear.Projection, y muchas otras librerias matemáticas para OpenGl, es que la coordenada z de 1 en el espacio de vista canonico es considerada la mas alejada y -1 la mas cercana, pero en realidad eres libre de usar cualquier combinacion que gustes. Cualquier fragmento con un valor fuera del rango de profundidad [0,1] será descartado, asi cualquier parte de las primitivas que intersectan la caja [(-1,-1,-1),(1,1,1)] en el espacio de vista canonico se convertirá en fragmentos en el view port. Esta caja es normalmente conocida como volumen de vista canonica (canonical view volume).

La posición de un vertice en el espacio de vista canonico se provee en realidad como un V4 VFloat, conocido como una coordenada 3D homogenea, donde V4 x y z w posee la posición 3D (x/w,y/w,z/w). Los tres vertices del triangulo en nuestro ejemplo usan 1 para la componente w, asi en este caso son simplemente coordenadas 3D comunes. Cuando se aplica “proyeccion perspectiva” (donde los objetos aparecen más pequeños cuanto más lejos estan, lo cual es standard en la mayoria de las aplicaciones 3D), la componente w no será 1. La razon por la cual el rasterizador quiere que w sea pasada de forma explicita en vez de hacer que dividamos los componentes por nuestra cuenta (mapeando una función de esa indole sobre el stream de primitivas), es que esta componente w es tambien usada cuando se realiza la interpolación de todos los demas valores del vertice. Voy a demostrar como funciona esta interpolación con corrección de perspectiva en una parte posterior, cuando veamos textures y samplers.

Ahora que hemos calculado que fragmentos generar para cada primitiva, y cuales posiciones de pantalla y valores de profundidad van a tener, podemos interpolar los demas valores de los vertices. El segundo argumento de la función rasterize es un stream de primitivas con tipo

FragmentInput a => PrimitiveStream p (V4 VFloat, a)

Y retorna un stream de fragmentos con tipo

FragmentInput a => FragmentStream (FragmentFormat a)

Esto significa que cada vertice tiene una posición homogenea como hemos discutido recien, pero tambien algun valor extra de tipo a que va a ser transformado en un valor de tipo FragmentFormat a en cada fragmento. Estos valores son producidos interpolando linealmente los valores de los vertices sobre toda la primitiva para cada fragmento. En nuestro ejemplo, a es V3 VFloat, representando el color de cada vertice. FragmentFormat a es un tipo asociado en la clase FragmentInput, y FragmentFormat (V3 VFloat) evalua a V3 FFloat. FFloat es como VFloat, una versión desplazada de Float, pero esta vez a un stream de fragmentos. Distinguimos los valores desplazados a un stream de vertices, de los valores desplazados a un stream de fragmentos, ya que la GPU no soporta exactamente el mismo conjunto de operaciones sobre ellos.

Dibujando e intercambiando

Lo ultimo que hacemos en nuestro shader, ahora que tenemos el fragmentStream, es dibujar los fragmentos en la ventana. drawContextColor toma como argumento a fragmentStream, pero tambien, asi como la mayoria de las demas acciones en la monada Shader, toma una función que extrae parametros del entorno del shader. En este caso el parametro extraido es un valor de tipo ContextColorOption, el cual especifica como los fragmentos deden ser combinados con los valores previos en la ventana. El valor que proveemos en nuestro ejemplo (nuevamente usando const, ya que no depende del entorno del shader), esta especificando que cada fragmento debe sobreescribir completamente el valor previo en la ventana. Voy a dedicar una parte completa de este tutorial a como dibujar, asi estos parametros seran explicados en detalle más adelante.

Ya que nuestra ventana fue creada con formato RGB8, el stream de fragmentos necesita contener valores de color de tipo V3 FFloat. Convenientemente, es el tipo exacto que tiene nuestro fragmentStream como resultado de la rasterización. Sin embargo, en la mayoria de los programas basados en GPipe vas a mapear funciones via fmap sobre el stream de fragmentos, para transformar los valores interpolados de la rasterizacion en el formato de color que es requerido por la ventana.

Dibujar es la unica acción en el shader que posee un efecto secundario: en este caso el buffer trasero de la ventana es alterado. Una ventana tiene (al menos) dos buffers, uno llamado buffer frontal que es mostrado en la pantalla, y otro que llamamos buffer trasero donde los shaders estan dibujando. Cuando la acción shader primitiveArray en la monada Render retorna, el buffer trasero sera actualizado. Para presentar en la pantalla esta nueva imagen renderizada, necesitamos llamar a swapContextBuffers dentro de nuestra monada ContextT. Esto le va a indicar al hardware grafico que intercambie los buffers frontal y trasero. No se va a realizar ninguna copia de memoria, sino solamente un intercambio de valores de punteros, asi que es bastante efectivo. Sin embargo, swapContextBuffers puede bloquearse momentaneamente si tratas de presentar imagenes mas rapido que la pantalla pueda actualizarse, pero esto es usualmente algo bueno, ya que de otra forma estarias gastando ciclos de GPU y CPU produciendo más imagenes de las que pueden presentarse.

Hay una linea en la acción Render de nuestro ejemplo, que omití antes descaradamente: clearContextColor (V3 0 0 0). Esta acción ocurre antes de ejecutar el shader, y es usada para setear cada pixel en los contenidos previos del buffer trasero a un valor constante, en este caso V3 0 0 0, es decir, color negro. Luego de un intercambio, los contenidos del buffer trasero son indefinidos, asi que es siempre una buena idea comenzar cada frame haciendo limpieza luego de swapContextBuffers. Limpiar y ejecutar shaders son dos acciones de la monada Render que tienen efectos secundarios.

Esto concluye la primer parte de este tutorial. La proxima vez voy a escribir detalladamente sobre Buffer y PrimitiveArray.

Comments are not open for this post yet.