The Sprite Batcher Class

As already discussed, a sprite can be easily defined by its position, size, and texture region (and optionally, its rotation and scale). It is simply a graphical rectangle in our world space. To make things easier we'll stick to the conventions of the position being in the center of the sprite and the rectangle constructed around that center. Now, we could have a Sprite class and use it like this:

Sprite bobSprite = new Sprite(20, 20, 0.5f, 0.5f, bobRegion);

That would construct a new sprite with its center at (20,20) in the world, extending 0.25 meters to each side, and using the bobRegion TextureRegion. But we could do this instead:

spriteBatcher.drawSprite(bob.x, bob.y, BOB_WIDTH, BOB_HEIGHT, bobRegion);

Now that looks a lot better. We don't need to construct yet another object to represent the graphical side of our object. Instead we draw an instance of Bob on demand. We could also have an overloaded method:

spriteBatcher.drawSprite(cannon.x, cannon.y, CANNON_WIDTH, CANNON_HEIGHT, cannon.angle, cannonRegion);

spriteBatcher.drawSprite(cannon.x, cannon.y, CANNON_WIDTH, CANNON_HEIGHT, cannon.angle, cannonRegion);

That would draw the cannon, rotated by its angle. So how can we implement the sprite batcher? Where are the Vertices instances? Let's think about how the batcher could work.

What is batching anyway? In the graphics community, batching is defined as collapsing multiple draw calls into a single draw call. This makes the GPU happy, as discussed in the previous chapter. A sprite batcher offers one way to make this happen. Here's how:

The batcher has a buffer that is empty initially (or becomes empty after we signal it to be cleared). That buffer will hold vertices. It is a simple float array in our case.

Each time we call the SpriteBatcher.drawSprite() method we add four vertices to the buffer, based on the position, size, orientation, and texture region that were specified as arguments. This also means that we have to manually rotate and translate the vertex positions without the help of OpenGL ES. Fear not, though, the code of our Vector2 class will come in handy here. This is the key to eliminating all the draw calls.

Once we have specified all the sprites we want to render, we tell the sprite batcher to actually submit the vertices for all the rectangles of the sprites to the GPU in one go, and then call the actual OpenGL ES drawing method to render all the rectangles. For this, we'll transfer the contents of the float array to a Vertices instance and use it to render the rectangles.

NOTE: We can only batch sprites that use the same texture. However, it's not a huge problem since we'll use texture atlases anyway.

The usual usage pattern of a sprite batcher looks like this: batcher.beginBatch(texture);

// call batcher.drawSprite() as often as needed, referencing regions in the texture batcher.endBatch();

The call to SpriteBatcher.beginBatch() will tell the batcher two things: it should clear its buffer and use the texture we pass in. We will bind the texture within this method for convenience.

Next we render as many sprites that reference regions within this texture as we need to. This will fill the buffer, adding four vertices per sprite.

The call to SpriteBatcher.endBatch() signals to the sprite batcher that we are done rendering the batch of sprites and that it should now upload the vertices to the GPU for actual rendering. We are going to use indexed rendering with a Vertices instance, so we'll also need to specify indices, in addition to the vertices in the float array buffer. However, since we are always rendering rectangles, we can generate the indices beforehand once in the constructor of the SpriteBatcher. For this we need to know how many sprites the batcher should be able to draw maximally per batch. By putting a hard limited on the number of sprites that can be rendered per batch, we don't need to grow any arrays of other buffers; we can just allocate these arrays and buffers once in the constructor.

The general mechanics are rather simple. The SpriteBatcher.drawSprite() method may seem like a mystery, but it's not a big problem (if we leave out rotation and scaling for a moment). All we need to do is calculate the vertex positions and texture coordinates as defined by the parameters. We have done this manually already in previous examples— for instance, when we defined the rectangles for the cannon, the cannonball, and Bob. We'll do more or less the same in the SpriteBatcher.drawSprite() method, only automatically based on the parameters of the method. So let's check out the SpriteBatcher. Listing 8-17 shows the code.

Listing 8-17. Excerpt from SpriteBatcher.java, Without Rotation and Scaling package com.badlogic.androidgames.framework.gl;

import javax.microedition.khronos.opengles.GLl0;

import android.util.FloatMath;

import com.badlogic.androidgames.framework.impl.GLGraphics; import com.badlogic.androidgames.framework.math.Vector2;

public class SpriteBatcher {

final float[] verticesBuffer; int bufferIndex; final Vertices vertices; int numSprites;

Let's look at the members first. The member verticesBuffer is the temporary float array we store the vertices of the sprites of the current batch in. The member bufferIndex indicates where in the float array we should start to write the next vertices. The member vertices is the Vertices instance is used to render the batch. It also stores the indices we'll define in a minute. The member numSprites holds the number drawn so far in the current batch.

public SpriteBatcher(GLGraphics glGraphics, int maxSprites) { this.verticesBuffer = new float[maxSprites*4*4];

this.vertices = new Vertices(glGraphics, maxSprites*4, maxSprites*6, false, true);

this.bufferIndex = 0; this.numSprites = 0;

short[] indices = new short[maxSprites*6]; int len = indices.length; short j = 0;

for (int i = 0; i < len; i += 6, j += 4) { indices[i + 0] = (short)(j + 0); indices[i + l] = (short)(j + l); indices[i + 2] = (short)(j + 2);

indices[i + 3] = (short)(j + 2); indices[i + 4] = (short)(j + 3); indices[i + 5] = (short)(j + 0);

vertices.setIndices(indices, 0, indices.length);

Moving to the constructor, we see that we have two arguments: the GLGraphics instance we need for creating the Vertices instance, and the maximum number of sprites the batcher should be able to render in one batch. The first thing we do in the constructor is create the float array. We have four vertices per sprite, and each vertex takes up four floats (two for the x- and y-coordinates and another two for the texture coordinates). We can have maxSprites sprites maximally, so that's 4 x 4 x maxSprites floats that we need for the buffer. Next we create the Vertices instance. We need it to store maxSprites x 4 vertices and maxSprites x 6 indices at most. We also tell the Vertices instance that we have not only positional attributes, but also texture coordinates for each vertex. We then initialize the bufferlndex and numSprites members to zero. Then we create the indices for our Vertices instance. We need to do this only once, as the indices will never change. The first sprite in a batch will always have the indices 0, 1, 2, 2, 3, 0; the next sprite will have 4, 5, 6, 6, 7, 4; and so on. We can precompute those and store them in the Vertices instance. This way we only need to set them once, instead of once for each sprite.

public void beginBatch(Texture texture) { texture.bind(); numSprites = 0; bufferlndex = 0;

Next up is the beginBatch() method. It binds the texture and resets the numSprites and bufferIndex members so the first sprite's vertices will get inserted at the front of the verticesBuffer float array.

public void endBatch() {

vertices.setVertices(verticesBuffer, 0, bufferlndex); vertices.bind();

vertices.draw(GL10.GL_TRIANGLES, 0, numSprites * 6); vertices.unbind();

The next method is endBatch(); we'll call it to finalize and draw the current batch. It first transfers the vertices defined for this batch from the float array to the Vertices instance. All that's left is binding the Vertices instance, drawing numSprites x 2 triangles, and unbinding the Vertices instance again. Since we use indexed rendering, we specify the number of indices to use—which is six indices per sprite times numSprites. That's all there is to rendering.

public void drawSprite(float x, float y, float width, float height, TextureRegion region) {

float halfWidth = width / 2; float halfHeight = height / 2; float x1 = x - halfWidth; float y1 = y - halfHeight;

float x2 = x + halfWidth; float y2 = y + halfHeight;

verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++]

verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++]

verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++]

verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++]

numSprites++;

The next method is the workhorse of the SpriteBatcher. It takes the x- and y-coordinates of the center of the sprite, its width and height, and the TextureRegion it maps to. The method's responsibility is to add four vertices to the float array starting at the current bufferIndex. These four vertices form a texture-mapped rectangle . We calculate the position of the bottom-left corner (x1,y1) and the top-right corner (x2,y2), and use these four variables to construct the vertices, together with the texture coordinates from the TextureRegion. The vertices are added in counterclockwise order, starting at the bottom-left vertex. Once they are added to the float array, we increment the numSprites counter and wait for either another sprite to be added or for the batch to be finalized.

And that is all there is to do. We just eliminated a lot of drawing methods by simply buffering pretransformed vertices in a float array and rendering them in one go. That will increase our 2D sprite-rendering performance considerably compared to the method we were using before. Fewer OpenGL ES state changes and fewer drawing calls make the GPU happy.

There's one more thing we need to implement: a SpriteBatcher.drawSprite() method that can draw a rotated sprite All we need to do is construct the four corner vertices without adding the position, rotate them around the origin, add the position of the sprite so that the vertices are placed in the world space, and then proceed as in the previous drawing method. We could use Vector2.rotate() for this, but that would mean some functional overhead. We therefore reproduce the code in Vector2.rotate() and optimize where possible. The final method of the SpriteBatcher looks like Listing 8-18.

Listing 8-18. The Rest of SpriteBatcher.java: A Method to Draw Rotated Sprites public void drawSprite(float x, float y, float width, float height, float angle,

Listing 8-18. The Rest of SpriteBatcher.java: A Method to Draw Rotated Sprites public void drawSprite(float x, float y, float width, float height, float angle,

TextureRegion

region) {

float

halfWidth = width / 2;

float

halfHeight = height / 2;

float

rad

= angle * Vector2.TO_RADIANS;

float

cos

= FloatMath.cos(rad);

float

sin

= FloatMath.sin(rad);

float

x1 =

-halfWidth * cos - (-halfHeight) * sin

float

y1 =

-halfWidth * sin + (-halfHeight) * cos

float

x2 =

halfWidth * cos - (-halfHeight) * sin;

float

y2 =

halfWidth * sin + (-halfHeight) * cos;

float

x3 =

halfWidth * cos - halfHeight * sin;

float

y3 =

halfWidth * sin + halfHeight * cos;

float

x4 =

-halfWidth * cos - halfHeight * sin;

float

y4 =

-halfWidth * sin + halfHeight * cos;

x1 +=

x;

y1 +=

y;

x2 +=

x;

y2 +=

y;

x3 +=

x;

y3 +=

y;

x4 +=

x;

y4 +=

y;

verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++]

verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++]

verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++]

verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++] verticesBuffer[bufferIndex++]

numSprites++;

We do the same as in the simpler drawing method, except that we construct all four corner points instead of only the two opposite ones. This is needed for the rotation. The rest is the same as before.

What about scaling? We do not explicitly need another method, since scaling a sprite only requires scaling its width and height. We can do that outside the two drawing methods, so there's no need to have another bunch of methods for scaled drawing of sprites.

And that's the big secret behind lighting-fast sprite rendering with OpenGL ES.

0 0

Post a comment