< Previous episode: Hello triangle
Welcome back! In last part you got your first triangle through GPipe. This time we are going to examine
Welcome back! In last part you got your first triangle through GPipe. This time we are going to examine
Buffer
s and PrimitiveArray
s in more detail.Buffers
In the "Hello world" example we made last time, we used a
A
Buffer
to store the triangle's positions and colors. From this buffer, we created a PrimitiveArray
that we fed to the shader. Later on, we will see that Buffer
s can be used for other things as well.A
Buffer
in GPipe is an array of data that is stored on the GPU. It is mutable just like IOArray
or STArray
and just like those it also lives in a monad, in this case the ContextT
monad. Let's look at the function that creates buffers first:newBuffer :: (MonadIO m, BufferFormat b) => Int -> ContextT ctx os m (Buffer os b)
A buffer has type
Buffer os b
, where os
is the same as the ContexT
's os
. As you might remember from last time this os
type parameter is used to keep context bound objects from leaving the monad, and Buffer
is such an object.newBuffer
just takes one argument: the number of elements in the buffer to create. A buffer has mutable elements, but the number of elements is immutable. The type of the buffer's elements is denoted by b
, and as you can see this b
is constrained by the type class BufferFormat b
. Before I show you that type class, let's look at the function you will use to fill your buffer with data from the CPU side:writeBuffer :: MonadIO m => Buffer os b -> BufferStartPos -> [HostFormat b] -> ContextT ctx os m ()
This function takes a buffer to write to and a zero indexed position in the buffer to start at, nothing strange with that, but then it takes a list of
HostFormat b
... What's up with that? A buffer's contents doesn't have the same representation on the host as in the buffer, which lives on the GPU. (From now on I am going to use the term "host" when I mean the normal CPU-living Haskell world, as opposed to the GPU world.) HostFormat b
is an associated type in the type class BufferFormat b
. Let's take a look at that class:class BufferFormat f where
type HostFormat f
toBuffer :: ToBuffer (HostFormat f) f
The sole purpose of this class is to provide a representation for the buffer elements' type on the host, as well as a conversion from the host to the buffer representation. Here are some examples of instances of this class, and their host representations:
f | HostFormat f | |
---|---|---|
B Float | Float | |
B Int32 | Int32 | |
B Word32 | Word32 | |
B2 Float | V2 Float | |
B2 Int32 | V2 Int32 | |
B2 Word32 | V2 Word32 | |
B2 Int16 | V2 Int16 | |
B2 Word16 | V2 Word16 | |
(a, b) | (HostFormat a, HostFormat b) | |
V2 a | V2 (HostFormat a) |
A
Float
on the host will become B Float
in a Buffer
. B a
is an opaque type that you can't inspect the value of or do any calculations with, e.g. there is no Num
instance for B Float
. Buffer
s doesn't expose a way to apply functions on their elements anyway (e.g. Buffer
has no Functor
instance), but we will soon create VertexArray
s from our Buffer
s and then this will become a subject.GPipe also defines the
B2 a
, B3 a
and B4 a
types. For a selected set of a
s, B2 a
is the buffer representation of V2 a
on the host. V2 a
is also an instance of BufferFormat
with V2 (HostFormat a)
as host representation, which means that both V2 (B Float)
and B2 Float
has the same host representation: V2 Float
. Both these buffer formats have the same size and even internal layout, but the B2 Float
version can be used more efficient as we will see later. For that reason, always try to use B-types over V-types in buffers when possible. Then why is there a BufferFormat
instance for V2 a
at all? The main use case is matrices, e.g. V4 (V4 Float)
on the host can be stored in a buffer as V4 (B4 Float)
.Another interesting thing you may have noticed from studying the
BufferFormat
instance list is that there are B2 Int16
and B2 Word16
instances but no B Int16
or B Word16
instances. This is because vertex attributes has to be 4 byte aligned on some hardware, and GPipe enforces this through it's types. Int16
and Word16
are both 2 bytes, so you need to have a vector of at least two of them. There are actually B3 Int16
and B3 Word16
instances, but these will pad their data with 2 byte extra. The motivation for all of this is that you could always go for a B Int32
instead of a B Int16
if it existed, they would work with the same shaders and would just be the same size anyway if we had padded the latter. A B3 Int32
on the other hand take 12 bytes while a padded B3 Int16
only takes 8, so there is a distinct use case for that one. A B4 Int16
also takes 8 bytes, but that wouldn't work with the same shaders as will become evident in the next part of this tutorial.Now let's look at the
toBuffer
member of the BufferFormat
type class. It has the type ToBuffer (HostFormat f) f
. ToBuffer
is something that is called an arrow in Haskell. It is like a function (in this case HostFormat f -> f
), but more general. Let's look at the BufferFormat (a, b)
instance as an example:{-# LANGUAGE Arrows #-}
instance (BufferFormat a, BufferFormat b) => BufferFormat (a, b) where
type HostFormat (a,b) = (HostFormat a, HostFormat b)
toBuffer = proc ~(a, b) -> do
a' <- toBuffer -< a
b' <- toBuffer -< b
returnA -< (a', b')
Arrow notation almost looks like a lambda (using the special keyword
proc
) returning a monadic action. But this is not a monad. The main difference from a monad is that you cannot select action based on the arrow return values. This is why arrow actions have an arrow tail (-<
); anything between the <-
and -<
of an arrow may not reference anything outside them (a
, b
, a'
or b'
in this case). This enforces that every invocation of toBuffer
must go through the same series of arrow actions, independent on the values of the actual input data. Another additional requirement that GPipe has is that it needs to be able to produce values lazily, thus the tilde (~
) in the proc
pattern. The only ToBuffer
arrow actions GPipe defines for you that you can use inside your own implementation of toBuffer
are the other instance's toBuffer
methods. You are going to see this pattern where an arrow is used to define a conversion between two domains appear in more places of GPipe as we continue through the tutorial.Vertex arrays
Ok, you are now experts on buffers! Let's put them to some use:
newVertexArray :: Buffer os a -> Render os (VertexArray t a)
You run this function in a
Render
monad to create a VertexArray t a
. A vertex array is like view of a buffer, and newVertexArray
doesn't copy any data. Since we operate inside the Render
monad (that is run by the render function, which doesn't allow return values) and Buffer
s can only be modified outside this monad (in the ContextT
monad), conceptually you may think of a VertexArray
as a copy of the Buffer
. But it's really not. But treat it like one.VertexArray t a
is an array of vertices where each vertex is an element of type a
, that is the same type as the elements of the Buffer
you created it from. Don't worry about the type parameter t
for now, I'll get to that in a bit. The VertexArray
has as many vertices as there are elements in the originating Buffer
, but in contrast to Buffer
s you may trim a VertexArray
using the functions dropVertices
or takeVertices
. These works exactly like drop
or take
works on normal lists:takeVertices :: Int -> VertexArray t a -> VertexArray t a Source
dropVertices :: Int -> VertexArray () a -> VertexArray t a Source
VertexArrays
also has a Functor
instance, which allows you to fmap
over its vertices. This is when the opacity of the B-types I talked about earlier comes into play! Now that you get to do stuff with your B-values, you will notice that the options are rather limited. You will merely pick elements from structures like tuples and/or build new such structures with the values you have. There are however a couple of functions that operate on the B-values that you can use here:toB22 :: forall a. (Storable a, BufferFormat (B2 a)) => B4 a -> (B2 a, B2 a)
toB3 :: forall a. (Storable a, BufferFormat (B3 a)) => B4 a -> B3 a
toB21 :: forall a. (Storable a, BufferFormat (B a)) => B3 a -> (B2 a, B a)
toB12 :: forall a. (Storable a, BufferFormat (B a)) => B3 a -> (B a, B2 a)
toB11 :: forall a. (Storable a, BufferFormat (B a)) => B2 a -> (B a, B a)
These may split B-vectors into smaller parts. Notice that there are no functions that can combine them again though.
You may also zip two
VertexArrays
together with the zipVertices
function, which works exactly like zipWith
on normal lists; you provide a function to combine the elements of the two argument VertexArray
s and the resulting VertexArray
will be the length of the shorter of the two input arrays:zipVertices :: (a -> b -> c) -> VertexArray t a -> VertexArray t' b -> VertexArray (Combine t t') c
(Again, don't mind the strange first type parameter in the returned VertexArray, I'll explain that later.)
Zipping vertex arrays is what corresponds to using non-interleaved arrays in OpenGl, while a vertex array from a single buffer of a compound element type (such as a tuple of two B-values) corresponds to interleaved arrays. This is just the functional and type safe way to do it!
Primitive arrays
Now that you have trimmed, zipped and mapped your vertex array into perfection, it's time to create a primitive array. The simplest way to create one is with this function:
toPrimitiveArray :: PrimitiveTopology p -> VertexArray () a -> PrimitiveArray p a
You always need a primitive topology, besides your array of vertices, to create a
PrimitiveArray
. The primitive topology denotes how the vertices should be connected to form primitives, and is one of these data constructors:data PrimitiveTopology p where
TriangleList :: PrimitiveTopology Triangles
TriangleStrip :: PrimitiveTopology Triangles
TriangleFan :: PrimitiveTopology Triangles
LineList :: PrimitiveTopology Lines
LineStrip :: PrimitiveTopology Lines
LineLoop :: PrimitiveTopology Lines
PointList :: PrimitiveTopology Points
In most cases you will work with triangles. Lets look at how the three triangle topologies look like:
(a) - TriangleStrip, (b) - TriangleFan, (c) - TriangleList
(Image courtesy of OpenGl specification by Khronos)
In a
The vertices always comes in counter clock wise order for a triangle that is front facing (which means that all triangles but the right-most in the image above are back facing, just as an example on how intuitive the OpenGl specification can be). The facing of a triangle will matter later when we rasterize it, when you may choose to only rasterize front facing or back facing triangles.
Primitive arrays may not be trimmed like vertex arrays, but it does have a
TriangleStrip
, every vertex forms a triangle with the previous two vertices, alternating the winding of the vertices for every other triangle. That means that the first triangle is formed by vertices 1-2-3 in that order, the next by 2-4-3, then 3-4-5, 4-6-5, and so on. For TriangleFan
, every triangle is formed by the first vertex in the array together with every two consecutive vertices, in that order. For TriangleList
, every three vertices simply forms a triangle; there is no sharing of vertices between triangles.The vertices always comes in counter clock wise order for a triangle that is front facing (which means that all triangles but the right-most in the image above are back facing, just as an example on how intuitive the OpenGl specification can be). The facing of a triangle will matter later when we rasterize it, when you may choose to only rasterize front facing or back facing triangles.
Primitive arrays may not be trimmed like vertex arrays, but it does have a
Functor
instance so you may fmap
over it just like with vertex arrays. It also has a Monoid
instance, which allow you to concatenate two PrimitiveArray
s together into one using mappend
. This makes it possible to create a PrimitiveArray
consisting of several disjoint triangle strips, but more efficient ways of achieving that are presented in the next two sections.Index arrays
It is common that a vertex is used by not only two consecutive triangles in a strip, but also by triangles in another strip. It would be quite wasteful to duplicate all the shared vertices data for each strip, and for this reason you can use an index array instead:
toPrimitiveArrayIndexed :: PrimitiveTopology p -> IndexArray -> VertexArray () a -> PrimitiveArray p a
Instead of forming primitives from taking consecutive vertices in a
VertexArray
, this function will take indices from an IndexArray
and use those to pick vertices from the VertexArray
. Multiple elements in the IndexArray
may refer to the same vertex. The primitive topology works the same for this function, but is applied to the IndexArray
instead. For example if TriangleStrip
is used the first triangle is formed by the vertices referred by the first three indices, the next one is formed by the second, fourth and third index, and so on.
You create an
IndexArray
withnewIndexArray :: forall os b a. (BufferFormat b, Integral a, IndexFormat b ~ a) => Buffer os b -> Maybe a -> Render os IndexArray
Almost like creating a
VertexArray
, but the type of the elements of the Buffer
you create it from is also constrained by this type family:type family IndexFormat a where
IndexFormat (B Word32) = Word32
IndexFormat (BPacked Word16) = Word16
IndexFormat (BPacked Word8) = Word8
This means that indices are either
Word32
, Word16
or Word8
. Remember that I previously told you that all buffer element types needed to be 4-byte aligned? Index arrays actually require all elements to be tightly packed, but still supports indices of type Word16
and Word8
. This means that buffers of those two element types cannot be used as vertex arrays. That's why the buffer representation of Word16
and Word8
are BPacked Word16
and BPacked Word8
. They work exactly like their B
-counter parts, with the exception that there are no VertexInput
instances for any BPacked a
. VertexInput
is the type class that is used when creating primitive streams from primitive arrays, which we will do in the next part of this tutorial. As you might have guessed by now, the type family IndexFormat a
evaluates to the same types as the associated type HostFormat a
.
Besides a buffer of indices,
newIndexArray
also takes a Maybe a
as an argument. This is denoting an optional primitive restart index, i.e. a special index value that if encountered in the index array while assembling primitives signals that the current topology should end and the next index to be the beginning of a new topology. This makes it possible to have multiple triangle strips in a single IndexArray
by just separating them with a special index, which is more efficient than concatenating multiple PrimitiveStream
s using their Monoid
instance.
Index arrays may be trimmed just like vertex arrays, but with the functions
takeIndices
and dropIndices
instead. It does not have a Functor
instance (that wouldn't make sense) or a Monoid
instance.Instanced primitive arrays
The last thing I'll show you in this episode is instanced primitive arrays. Imagine that you want to create a triangle mesh of a temple, where you have many identical pillars placed in a row. Instead of duplicating the triangles for each pillar, or making a single pillar primitive array that you concatenate with itself multiple times, you can create an instanced primitive array. The function looks like this:
toPrimitiveArrayInstanced :: PrimitiveTopology p -> (a -> b -> c) -> VertexArray () a -> VertexArray t b -> PrimitiveArray p c
It resembles the
If you want to use instanced primitive arrays and indexed primitive arrays at the same time, there is a function for that too:
zipVertices
function in that it takes two VertexArray
s and a binary function to combine the vertices of these two arrays, but toPrimitiveArrayInstanced
doesn't zip the two arrays together. Instead, it will create one primitive array of the first vertex array for each vertex in the second vertex array, and concatenate the results. In our example with temple pillars, the first array then contains the strip for a single pillar, while the second array contains a position for each pillar to instantiate, resulting in a primitive array where each vertex contains it's individual position within the pillar, as well as the instances position within the temple. You would then need a shader that combined those two positions together into the final position. This is the most efficient way to render multiple instances of the same object.If you want to use instanced primitive arrays and indexed primitive arrays at the same time, there is a function for that too:
toPrimitiveArrayIndexedInstanced :: PrimitiveTopology p -> IndexArray -> (a -> b -> c) -> VertexArray () a -> VertexArray t b -> PrimitiveArray p c
To make instancing even more powerful, you may replicate the vertices in one array a fixed number of times each and then zip it with another array and use the result as instances in
toPrimitiveArrayInstanced
. E.g. you could have a vertex array with three different colors and replicate each color 5 times and then zip it with an array of 15 positions and use this as instances to our temple to get 15 pillars colored in three different shades for variation. The function you use to do this isreplicateEach :: Int -> VertexArray t a -> VertexArray Instances a
This will replicate each vertex in the argument array as many times as dictated by the first argument. Notice the
Instances
type in the first type parameter of the resulting VertexArray
. Maybe you have noticed that this parameter has previously been ()
or just t
. If this parameter to VertexArray
is Instances
then the VertexArray
can only be used for instances, i.e. as last argument in a call to toPrimitiveArrayInstanced
or toPrimitiveArrayIndexedInstanced
. If you go back and look at the types of functions taking or returning VertexArray
s above, you will see thatreplicateEach
returns aVertexArray
that must be used as instances.dropVertices
may not be used on anyVertexArray
that must be used as instances.zipVertices
returns aVertexArray
that must be used as instances if either of the input arrays must be used as instances.
I was a bit unfair just now, because that last bullet was not something you could see from looking at the function type alone, you needed this definition as well:
type family Combine t t' where
Combine () Instances = Instances
Combine Instances () = Instances
Combine Instances Instances = Instances
Combine () () = ()
Once you have your
PrimitiveArray
, the type information whether instancing, indexing or both were used is gone. This means that you may mappend
an instanced primitive array with a non-instanced, and that the shader you send a primitive array to doesn't care if it was instanced or indexed.Shader
!