A sprite atlas is a collection of sprites that are stored in a single image which becomes a single texture when rendering using OpenGL.
By storing many sprites in one texture we gain significant performance benefits as switching textures is a costly operation. We saw how we can render sprites out of a sprite atlas in the animation tutorial but we did so in an ad-hoc way for illustration purposes. Sprite atlases are central to our 2D engine so here we will define a proper data structure for them. This is the atlas definition in AGL:
type Atlas struct {
*image.RGBA
atlasImage *image.Gray
coverageImage []math.Box2D[int]
spriteBoundingBoxes uint32 // OpenGL texture of Atlas
atlasTexture }
Lets go over the fields. Field atlasImage is the atlas image in
CPU space, loaded from disk using Go’s image
package.
Field spriteBoundingBoxes
is a list of bounding boxes
given as a pair of points (lower-left and upper right). For
reference, here is what a Box2D
looks like.
type Box2D struct {
, P2 Vector2[int]
P1}
These bounding boxes correspond to the location of sprites on the image.
In this example the image is
.
The sprite in the blue border will have an entry in
spriteBoundingBoxes
with the values
P1:{0,0}, P2:{16,16}
. The sprite in the green border
will have P1:{32,16}, P2:{48,32}
. These values are
useful to create uv (texture) coordinates for rendering.
Field atlasTexture
is the OpenGL texture that
stores atlasImage
. We keep the OpenGL texture here so
that we can update it when we update atlasImage
. The
final field, coverageImage
is a helper image used
when adding sprites to the atlas (more on that in a bit).
There are two ways to build an atlas. The simplest (code-wise) is to create the atlas image in an external program and then load it into the Atlas struct. In code1 this looks like this:
func NewAtlas(atlasImage *image.RGBA, spriteBoundingBoxes []math.Box2D[int]) *Atlas {
:= Atlas{
st : atlasImage,
atlasImage: spriteBoundingBoxes,
spriteBoundingBoxes}
var err error
.atlasTexturetextureFromRGBA(st.atlasImage)
streturn &st
}
The code simply loads the required data into the struct and
then builds the OpenGL texture using textureFromRGBA
which we have used before. If we are loading the atlas from disk,
the bounding boxes are given in JSON form:
func NewAtlasFromFiles(atlasImage, spriteBoundingBoxes string) *Atlas {
:= []math.Box2D[int]{}
bboxes ,_ := ioutil.ReadFile(spriteBoundingBoxes)
dat.Unmarshal(dat, bboxes)
json,_ := RgbaFromFile(atlasImage)
imgreturn NewAtlas(img, bboxes)
}
This process relies on us having an external program that packs sprites into an image and keeps track of sprite bounding boxes in the format that our program requires. There are various programs for creating sprite sheets, such as Libresprite, which does export sprite bounding boxes in JSON format. Although its format doesn’t match ours, writing a converter wouldn’t take more than a few lines of code.
The other way to build the atlas is to load each sprite one by one. This is desirable if the user has a collection of images that they want to use as sprites but they haven’t arranged them in a sprite atlas. We can create the atlas automatically with a little bit of bookkeeping.
First lets see how to create an empty atlas using our constructor:
(image.NewRGBA(image.Rect(0, 0, 1024, 1024)), nil) NewAtlas
This allocates space for an empty
atlas. We can then copy images into the atlas using Go’s
draw
package:
:= image.Rectangle{p1, p2}
rect .Draw(atlasImage, rect, img, image.Point{}, draw.Src) draw
This would copy img
into our
atlasImage
at the image location given by the
rectangle rect
which specifies a bounding box like
our own math.Box2D
. We then need a way to find empty
spots on the atlas where we can place our new sprites. This is
where the auxiliary coverageImage
comes in. This is a
black and white image where where black pixels signify empty space
and white pixels signify that the space is taken by a sprite. To
find a place we iterate over all pixels in the coverage until we
find a black pixel.
for y := 0; y < s.coverageImage.Bounds().Max.Y; y++ {
for x := 0; x < s.coverageImage.Bounds().Max.X; x++ {
if s.coverageImage.At(x, y) == white {
continue
}
// found empty pixel at (x,y)
}
}
When we find an empty pixel we need to check that there is
enough space to fit the sprite we are inserting. We could do that
by checking each pixel starting from (x,y)
and
spanning the size of the sprite we are about to insert
(x+sprite.width, y+sprite.height)
. If all pixels in
that region are black (empty) then we are good to place our sprite
there. If any pixel is white we can’t and need to move forward to
check other pixels. Alternatively, we can check the bounding boxes
in our atlas. If none of our bounding boxes overlaps the box given
by the points (x,y)
and
(x+sprite.width, y+sprite.height)
we are free to
place our sprites there. Both approaches are valid. Checking pixel
by pixel is better if the inserted sprite has less pixels than the
number of bounding boxes in the atlas. If sprites are large and
the number of bounding boxes small, checking the bounding boxes is
faster. In AGL we chose to use the second approach.
func (s *Atlas) checkCoverageSpot(x, y, w, h int) bool {
:= math.Box2D[int]{}.New(x, y, x+w, y+h)
box for _, b := range s.spriteBoundingBoxes {
if b.Overlaps(box) {
return false
}
}
return true
}
Checking if two boxes overlap is done with this piece of code. Take a moment (and possibly a pen and paper) to check this code’s correctness.
func (b Box2D) Overlaps(other Box2D) bool {
return b.P1.X < other.P2.X &&
.P2.X > other.P1.X &&
b.P1.Y < other.P2.Y &&
b.P2.Y > other.P1.Y
b}
With this, the complete code for finding an empty spot becomes:
:= image.Point{-1, -1}
p1
for y := 0; y < s.coverageImage.Bounds().Max.Y; y++ {
:= false
done for x := 0; x < s.coverageImage.Bounds().Max.X; x++ {
if s.coverageImage.At(x, y) == white {
continue
}
if s.checkCoverageSpot(x, y, img.Bounds().Dx(), img.Bounds().Dy()) {
= image.Point{x, y}
p1 = true
done break
}
}
if done {
break
}
}
Once we find a spot we copy (draw) the sprite to the atlas.
:= p1.Add(spriteToAdd.Rect.Max)
p2 := image.Rectangle{p1, p2} // bounding box of sprite
rect .Draw(s.atlasImage, rect, spriteToAdd, image.Point{}, draw.Src) draw
We also update the coverage image, painting the spot where we added the sprite white so the next insertion will know not to place anything there.
.Draw(s.coverageImage, rect, image.NewUniform(white), image.Point{}, draw.Src) draw
Finally, we create an entry for the bounding box of the newly added sprite.
.spriteBoundingBoxes = append(s.spriteBoundingBoxes, math.Box2D[int]{
s: math.Vector2[int]{p1.X, p1.Y}, P2: math.Vector2[int]{p2.X, p2.Y},
P1})
.UpdateGPUAtlas() s
We also update the OpenGL texture so we are ready to render the
new sprite. This is not ideal, as updating the texture is costly
and the user is likely to add a bunch of sprites in a row (e.g
when loading a folder full of sprites into the atlas) in which
case we would only want to update the texture after the last
sprite has been added. For this reason, in AGL we provide an
AddImages
function that does that.
Lets look at an example of how the process works. We have the following ‘sprites’ that we want to add in an empty atlas:
When we add the first sprite, our atlas and coverage images are empty so our search for an empty spot will stop at the first pixel of the atlas and the first sprite will be placed there. The coverage image is painted white to indicate where the sprite was placed.
For the next sprite, the search will skip the first row of white pixels and find an empty spot right next to the first sprite:
For the third sprite, the search will find an empty pixel next to the second sprite but the sprite we are adding doesn’t fit there. In the example code above we don’t test for this scenario but the AGL implementation does. As a result, the search will continue past the area occupied by sprite two.
The fourth sprite fits under sprite three.
And the final sprite fits next to sprite two. Notice that we start searching from pixel every time which allows us to reuse space that was previously discarded.
As you might imagine, the order that we add the sprites affects the outcome. Here are the same sprites added in another order:
The code used to create these images can be found here.
The order in which we add sprites will affects how tightly packed the atlas is. This might be important for saving space on the atlas, especially if we are storing larger sprites together with smaller ones. We can spend a good bit of time devising heuristics to achieve this2, but a simple one is to sort the sprites by descending area:
For transparent sprites there exist more elaborate packing strategies that let sprites be placed inside other sprite’s bounding boxes (occupying the other sprite’s transparent regions). Even more gains can be achieved if we are willing to rotate sprites and segment them with complex polygons instead of boxes. Here is an example of TexturePacker’s3 polygon algorithm:
AGL only supports square sprites, which is what is used in most 2D games anyway. The simple packing approach described here gets us 90% of the performance with 10% of the effort required for implementing the more fancy packing methods out there.