In OpenGL, the CPU-side code is mostly copying data to GPU buffers with code like this.
var vertexVbo uint32
.GenBuffers(1, &vertexVbo)
gl.BindBuffer(gl.ARRAY_BUFFER, vertexVbo)
gl.VertexAttribPointer(0, 3, gl.FLOAT, false, 0, nil)
gl.EnableVertexAttribArray(0)
gl.BufferData(gl.ARRAY_BUFFER, 4*len(triangles), gl.Ptr(&triangles[0]), gl.STATIC_DRAW) gl
In this tutorial we will create a BufferList
type
that encapsulates the storing and copying of data from the CPU to
the GPU. This type will be able to create all the necessary
buffers needed to use a shader so it will rely on the
Shader
type we defined in an earlier tutorial.
The following is the basis for our BufferList
object.
type BufferList struct {
.Shader
Shader shaders[][]float32
CpuBuffers []uint32
GpuBuffers []int32
TypeSize int
NumSprites int
MaxSprites int
RenderOrder uint32
Texture }
A BufferList
stores the data needed for a specific
Shader
. It is convenient to keep a copy of the shader
in the object. The Shader object is lightweight so don’t need to
use a reference.
CpuBuffers
is a list of lists that holds our data
on the CPU side. For each list in CpuBuffers
we have
an entry in GpuBuffers
. Each GPU buffer entry just
needs a uint32
identifier since the actual storage is
handled by OpenGL.
Parameters NumSprites
and MaxSprites
hold the current number and max number of sprites in the buffers
respectively. We use MaxSprites
to preallocate our
buffers. Preallocating is important for performance reasons. Go
slices can by dynamically expanded but we generally don’t want to
do that while our game is running because it can be slow. Instead,
we preallocate a big array during game setup and afterwards we can
add data to our buffers really quickly. For performance reasons we
also don’t delete data from the buffers. Instead, we write over
existing data and use NumSprites
to make sure we
don’t render expired data.
The downside of using preallocated buffers is that we must have a good estimate about the number of sprites we expect to store. Otherwise, we risk running out of space if underestimate. If we overestimate, we waste space.
Parameter RenderOrder
is used by the renderer to
control the order that BufferList
objects are
processed. The order of rendering is important if we are rendering
transparent sprites or if we are rendering without depth testing
enabled. This parameter is only used by the renderer and we won’t
need it here.
The last field of our object is Texture
which
holds the id of the the texture used by the sprites stored in our
buffers. The texture itself is created in a
SpriteAtlas
and we store it here only as a
convenience.
The BufferList
constructor is initialized based on
a shader that we pass as a parameter. Initially, we create the
struct with all the parameters that can be copied in. Cpu and Gpu
buffers are then preallocated getting an entry for each shader
attribute in shader
.
func NewBufferList(shader shaders.Shader, maxSprites, renderOrder int, texture uint32) (*BufferList, error) {
:= BufferList{
sb : renderOrder,
RenderOrder: shader,
Shader: make([][]float32, len(shader.Attributes)-1),
CpuBuffers: make([]uint32, len(shader.Attributes)-1),
GpuBuffers: maxSprites,
MaxSprites: 0,
NumSprites: texture,
Texture}
...
Next, we sort the attributes by location to ensure that they are added in the correct order. This is because go maps do not guarantee the ordering of stored values. Having the shader attributes stored in the order they appear in the shader is very beneficial as we can index them directly.
// same sorting as sprite.shaderData
:= ds.SortMapByValue(shader.Attributes, func(a, b shaders.ShaderAttribute) bool {
attrKeys return a.Location < b.Location
})
The final step is to allocate the buffer for each attribute. We use the sorted keys to ensure correct ordering.
.GenVertexArrays(1, &sb.VAO)
gl.BindVertexArray(sb.VAO)
gl
for i, v := range attrKeys {
:= shader.Attributes[v]
attr if attr.Name == "vertex" {
()
createSpriteVertexBuffercontinue
}
// intialize empty cpu buffers of fixed size
.CpuBuffers[i-1] = make([]float32, maxSprites*int(attr.Type.Size))
sb// initialize GPU buffers
.GpuBuffers[i-1] = newGLBuffer(maxSprites*int(attr.Type.Size), int(attr.Type.Size), attr.Location)
sb.TypeSize = append(sb.TypeSize, attr.Type.Size)
sb}
.BindVertexArray(0)
glreturn &sb, nil
}
For Cpu buffers we allocate space for our maximum number of
allowed sprites maxSprites*int(attr.Type.Size)
. We
store Type.Size
in the helper array
TypeSize
as we will be accessing it multiple times
and we don’t want to use Shader.Attributes because it is a map
(its better to avoid accessing a map in a very fast loop such as
our render loop).
For Gpu buffers we use the initialization process that we saw
in the instancing tutorial. All
buffers are initialized with newGLBuffer
seen below
except for the vertex buffer.
// Allocate a buffer object
func newGLBuffer(size, typesize int, location uint32) uint32 {
var vbo uint32
.GenBuffers(1, &vbo)
gl.BindBuffer(gl.ARRAY_BUFFER, vbo)
gl.VertexAttribPointer(location, int32(typesize), gl.FLOAT, false, 0, nil)
gl.EnableVertexAttribArray(location)
gl.BufferData(gl.ARRAY_BUFFER, 4*size, gl.Ptr(nil), gl.DYNAMIC_DRAW) // 4 bytes in a float32
gl// this sets the buffer index to move once per instance instead of once per vertex so all
// vertices in the instance get the same value
.VertexAttribDivisor(location, 1)
glreturn vbo
}
The vertex buffer does not need the
VertexAttribDivisor
call since it is the attribute
that will be instantiated. A minor difference is that we set the
vertex array to STATIC_DRAW
since this array will
never be updated because it will always store our sprite
template.
func createSpriteVertexBuffer() uint32 {
var vbo uint32
.GenBuffers(1, &vbo)
gl.BindBuffer(gl.ARRAY_BUFFER, vbo)
gl.VertexAttribPointer(0, 3, gl.FLOAT, false, 0, nil)
gl.EnableVertexAttribArray(0)
gl.BufferData(gl.ARRAY_BUFFER, 4*len(spriteTemplateCentered), gl.Ptr(&spriteTemplateCentered[0]), gl.STATIC_DRAW)
glreturn vbo
}
One inconvenience with our vertex buffer is that when we want to use it we have to iterate over every buffer and bind it. Fortunately, we can fix that using an OpenGL mechanism called vertex array objects (VAO). VAOs store collections of vertex buffer objects (VBO). To use a VAO we must first generate it similarly to how we generate VBOs:
var vao uint32
.GenVertexArrays(1, vao) gl
We then bind the VAO to enable it and do our buffer allocation as usual.
.BindVertexArray(vao)
gl
// create a bunch of VBOs
for buffer := range buffers{
(...)
newGLBuffer}
.BindVertexArray(0) gl
When we are done we unbind the VAO by binding the zero VAO (please take a moment to appreciate this intuitive OpenGL API). When we want to use our buffers now we don’t have to bind each VBO individually, we just bind the VAO:
.BindVertexArray(vao)
gl.DrawArraysInstanced(...)
gl.BindVertexArray(0) gl
We will store the VAO identifier in the BufferList
object for easy access:
type BufferList struct {
int
RenderOrder .Shader
Shader shaders[][]float32
CpuBuffers []uint32
GpuBuffers []int32
typeSize int
NumSprites int
MaxSprites uint32
Texture uint32 // Vertex Array Object
VAO }
The BufferList
object is used in conjunction with
the renderer and is used in the render loop. We will go over the
render loop in detail in another tutorial but for now its useful
to know the basic steps in the loop.
Steps 1,2 and 3 are BufferList
functionality that
we will see now.
The AddSprite
method is our way of adding data to
the buffers. It checks NumSprites
to see if there is
space to add more and exits if not. If there is space available it
copies data from a Sprite
object into our CPU
buffers.
func (bf *BufferList) AddSprite(sprite *Sprite) error {
if bf.NumSprites == bf.MaxSprites {
return errors.New("Store full")
}
for i := range bf.CpuBuffers {
.UpdateBuffers(i, bf.NumSprites, sprite.shaderData[i].Data)
bf}
.NumSprites++
bfreturn nil
}
We haven’t defined the Sprite
object yet but the
only thing we need from it here is the data that goes in the
buffers. These are stored in a slice that has an entry for each shader attribute.
type Sprite struct {
//...
[]*shaders.ShaderAttribute
shaderData //...
}
The shaderData
slice is ordered in the same way we
order our CpuBuffers
so we just iterate through and
copy:
func (sb *BufferList) UpdateBuffers(buffer, index int, data []float32) {
:= int(sb.TypeSize[buffer])
typeSize := index * typeSize
startIndex copy(sb.CpuBuffers[buffer][startIndex:startIndex+len(data)], data)
}
We don’t update the GPU buffers at this point because we will
likely be adding many sprites and copying to the GPU is a high
latency operation. Instead, when when we have added all the
sprites to the CPU buffers we copy them to the GPU all at once.
This is done with MoveCpuToGPU
.
func (bf *BufferList) MoveCpuToGpu() int {
:= 0
dataMoved if bf.NumSprites <= 0 {
return 0
}
for i := range bf.CpuBuffers {
:= int(bf.TypeSize[i])
typeSize := bf.CpuBuffers[i][0 : typeSize*bf.NumSprites]
data (bf.GpuBuffers[i], data, 0)
updateGLBuffer+= typeSize * bf.NumSprites
dataMoved }
return dataMoved
}
func updateGLBuffer(glbuffer uint32, newData []float32, position int) {
.BindBuffer(gl.ARRAY_BUFFER, glbuffer)
gl.BufferSubData(gl.ARRAY_BUFFER, 4*position, 4*len(newData), gl.Ptr(newData))
gl}
This loops through all CPU buffers, calculates the number of
floats that must be moved (typeSize*bf.NumSprites
)
and creates a slice of the data that must be moved.
data := bf.CpuBuffers[i][0 : typeSize*bf.NumSprites]
The above doesn’t trigger any copying. In Go, slicing just
creates a pointer to the original array with two indices
start
and end
so there is no performance
penalty for this. The slice is then passed to
updateGLBuffer
which binds the appropriate GPU buffer
and copies the data over. This function is able to partially
update a GPU buffer by copying after a start index given by
position
but we don’t use this functionality here and
we always copy from index zero. It is the responsibility of the
renderer to decide when to trigger MoveCpuToGpu
.
To clear the buffers we just set NumSprites
to
zero.
func (sb *BufferList) Empty() {
.NumSprites = 0
sb}
We can do this because our buffers are pre-allocated. When we
call AddSprite
to add data again it will simply
overwrite existing data. When we copy to the GPU we only copy up
to NumSprites
so we are not rendering old data.
Our buffers object is very strongly interlinked with the
renderer which we will see in an upcoming tutorial which is why
some of the details of the implementation, like how we use the VAO
object, might not be very clear yet. Also, the
BufferList
object is intended to be used internally
by the engine and the user should never have to create one
manually which is why we allowed some programming war crimes like
storing a reference to the atlas texture which is a member of
another object!