Sprite
Sprite
is the object that holds the data needed to
render a sprite. We already saw in the buffers tutorial that
Sprite
holds rendering data that we copy to
BufferList
. Unlike BufferList
which is managed
by the engine itself, Sprite
is a struct that our users
will be creating and using directly so we aim to provide easy to use
functions for it.
Structure
The following is our Sprite
type:
type Sprite struct {
index int
renderOrder int
atlas *Atlas
shader *shaders.Shader
shaderData []*shaders.ShaderAttribute
bufferIndex int
}
The Sprite
structure holds references to the structures
needed to render it: the sprite atlas that holds the sprite’s uvs and
the shader that will do the shading. A sprite has an index
which uniquely identifies it in the sprite atlas. Also, a sprite has a
renderOrder
which is the order in which
Renderer
will process this sprite. A sprite holds buffers
for each shader attribute of shader
.
Notice that all attributes of Sprite
start with a
lowercase letter. This is Go’s equivalent of a private field and it is
not accessible to users - it’s only accessible to the library itself. We
do this to force users to use our setter functions which ensure that
shader data is correctly set.
Constructor
The Sprite
constructor creates a sprite given a sprite
atlas, a shader and the sprite’s id (we discuss parameter
renderOrder
in the next section).
func NewSprite(spriteId int, spriteAtlas *Atlas, shader *shaders.Shader, renderOrder int) (Sprite, error) {
spr := Sprite{
index: spriteId,
shaderData: []*shaders.ShaderAttribute{},
atlas: spriteAtlas,
shader: shader,
bufferIndex: -1, // negative means the renderer hasn't assigned a BufferList for us yet
renderOrder: renderOrder,
}
// sort shader attributes by their location, this way sprite attributes order implicitly matches
// BufferList arrays which are sorted in the same way
attrKeys := ds.SortMapByValue(shader.Attributes, func(a, b shaders.ShaderAttribute) bool {
return a.Location < b.Location
})
// allocate arrays for each attribute
for _, v := range attrKeys {
attr := shader.Attributes[v]
if attr.Name == "vertex" {
continue
}
newAttr := attr.Copy()
spr.shaderData = append(spr.shaderData, &newAttr)
}
spr.SetIndex(spriteId)
// set original size as the default
spr.SetOriginalSize()
return spr, nil
}
The constructor initializes the shaderData
array which
holds the data for shader attributes (position, uv, color etc). Just
like BufferList
, we sort these attributes by their location
so we guarantee that when the sprites are added to the
BufferList
via BufferList.AddSprite
everything
gets copied correctly.
Next, we must assign the sprite’s UV which come from the sprite atlas. If you recall, sprite atlas stores the locations of its sprites in a list:
spriteBoundingBoxes[]math.Box2D[int]
Parameter spriteId
is the index into the above array.
This information is used to create the UV coordinates of the sprite
using SetIndex
. This is a setter function that assigns the
id to the index
field of Sprite
and pulls the
uv data from the atlas using the same key to store it. This function can
also be used to switch a sprite to another sprite in the atlas.
func (s *Sprite) SetIndex(index int) {
s.index = index
uvs := s.atlas.GetSpriteUVs(index)
copy(s.shaderData[uvIndex].Data, uvs[:])
}
The last piece of initialization is to set the sprite its original size. This way, if a sprite takes pixels on the atlas it will appear as pixels when we render. This is just a default and the user can set whatever size they want using the accessor functions that we will see in a bit.
Parameter bufferIndex
is used by the renderer and will
be explained in the renderer tutorial.
Initializing from an Image
Our convention is that sprites can only come from a sprite atlas so if the user wants to render images they have to first add them to an atlas and then create a sprite.
atlas, _ := sprite.NewAtlas(image.NewRGBA(image.Rect(0, 0, 1024, 1024)), nil) # create atlas
index, _ := atlas.AddImage(some_image) # add image
sprite, _ := sprite.NewSprite(index, atlas, &some_shader, 1) # make a sprite
Of course this can be automated and we can even provide a service that creates atlases automatically. This will be covered in an upcoming tutorial.
Render Order
Render order is used to define a group of sprites that all get rendered together. For example, all sprites with render order 1 could be background sprites and sprites with render order 2 could be foreground. There is no explicit ordering for sprites within the same render order. Render order is important for transparent sprites, like the letters seen on the left image below. Such sprites must be rendered after the sprites behind them otherwise transparency won’t work. In this example the sprites that make up the three letters must have higher render order than the sprite that makes the grey background. They must also be closer to the camera meaning they must have a smaller depth value.
Render order is not important for cutout sprites such as the letters seen on the image to the right. Here, transparency is binary meaning that it is either fully opaque (surface of the letters) or fully transparent (area around the letters). Depth is still important, and the depth value for the foreground letters must be smaller than the background. Cutout sprites are the most common and if a game uses only cutout sprites render order can be ignored (set it to 0 for every sprite).
As we will see later, the renderer creates a BufferList
for each render order so its important to not assign unnecessary render
order values if they are not needed as it will negatively impact
performance.
Setters
Setters are functions that set the field values of the
Sprite
struct. In the Go coding style, we do not normally
use setters and instead expose the fields for direct access. Here we
break this convention because setting shader data directly is error
prone and inconvenient. We will go over the sprite setter functions in
this section starting with SetPostition
.
SetPosition
Method SetPosition
sets a sprite’s position given a 3D
vector as parameter. These values are in pixels. By default, we map
position
to the bottom-left corner of the window but this can be changed in the
renderer.
func (s *Sprite) SetPosition(position math.Vector3[float32]) {
s.shaderData[translationIndex].Data[0] = position.X
s.shaderData[translationIndex].Data[1] = position.Y
s.shaderData[translationIndex].Data[2] = position.Z
}
Variable translationIndex
is part of an enumeration that
we setup to enable quick access to common sprite parameters:
const (
uvIndex = iota
colorIndex
translationIndex
rotateIndex
scaleIndex
)
This enumeration lets us change shaderData
in constant
time. This is important as we are likely to change some of the
parameters in every frame. A moving sprite, for example, changes its
position every frame. The above enumeration assumes that sprite
attributes are at fixed locations. We enforce this (very loosely!) by
setting the same order in Shader
.
var CommonAttributes = []string{"vertex", "uv", "color", "translation", "rotation", "scale"}
Since position is not exposed to the user, to get it we must provide
a method. For position this is unsuprisingly
GetPosition
:
func (s *Sprite) GetPosition() math.Vector3[float32] {
return math.Vector3[float32]{
X: s.shaderData[translationIndex].Data[0],
Y: s.shaderData[translationIndex].Data[1],
Z: s.shaderData[translationIndex].Data[2],
}
}
SetRotation, SetScale, SetColor
These setters just assign the passed value. The only benefit of having a setter function for these is that we can pass the value in a convenient vector type and it gets assigned to the correct shader array position.
// Setter for sprite rotation
func (s *Sprite) SetRotation(rot math.Vector3[float32]) {
s.shaderData[rotateIndex].Data[0] = rot.X
s.shaderData[rotateIndex].Data[1] = rot.Y
s.shaderData[rotateIndex].Data[2] = rot.Z
}
// Setter for sprite scale.
func (s *Sprite) SetScale(scale math.Vector2[float32]) {
s.shaderData[scaleIndex].Data[0] = scale.X
s.shaderData[scaleIndex].Data[1] = scale.Y
}
// Set the sprite color. The same color is applied to every vertice.
func (s *Sprite) SetColor(color color.ColorRGBA) {
s.shaderData[colorIndex].Data[0] = color.R
s.shaderData[colorIndex].Data[1] = color.G
s.shaderData[colorIndex].Data[2] = color.B
s.shaderData[colorIndex].Data[3] = color.A
}
Their equivalent getters are similarly simple:
func (s *Sprite) GetRotation() math.Vector3[float32] {
return math.Vector3[float32]{
X: s.shaderData[rotateIndex].Data[0],
Y: s.shaderData[rotateIndex].Data[1],
Z: s.shaderData[rotateIndex].Data[2],
}
}
func (s *Sprite) GetScale() math.Vector2[float32] {
return math.Vector2[float32]{
X: s.shaderData[scaleIndex].Data[0],
Y: s.shaderData[scaleIndex].Data[1],
}
}
func (s *Sprite) GetColor() color.ColorRGBA {
return color.NewColorRGBAFromSlice(s.shaderData[colorIndex].Data[0:4])
}
SetOriginalSize
SetOriginalSize function scales the sprite to its dimensions (in pixels) as it appears on the sprite atlas.
func (s *Sprite) SetOriginalSize() {
orig := math.Vector2ConvertType[int, float32](s.GetOriginalSize())
s.SetScale(orig)
}
GetOriginalSize is the equivalent getter.
func (s *Sprite) GetOriginalSize() math.Vector2[int] {
bb := s.atlas.GetBoundingBox(s.index)
return bb.Size()
}
SetIndex
This function allows us to switch the sprite to some other sprite on the atlas. Since the sprite doesn’t hold any image data itself, this is a very cheap operation as it just changes the shader values of the uv coordinates.
func (s *Sprite) SetIndex(index int) {
s.index = index
uvs := s.atlas.GetSpriteUVs(index)
copy(s.shaderData[uvIndex].Data, uvs[:])
}
This function can be used to create an animated sprite by keeping a list of indexes and switching between them.
Generic Setter and Getter
Shaders can have attributes other than the common ones that we enumerated. To get and set these we use these methods:
// General setter for sprite attributes.
func (s *Sprite) SetAttribute(location int, data []float32) {
if location >= len(s.shaderData) || location < 0 {
return
}
for i := 0; i < math.Min[int](len(s.shaderData[location].Data), len(data)); i++ {
s.shaderData[location].Data[i] = data[i]
}
}
// General getter for sprite attributes. Does not copy so use with care.
func (s *Sprite) GetAttribute(location int) []float32 {
if location >= len(s.shaderData) || location < 0 {
return nil
}
return s.shaderData[location].Data
}