- Use an overlay to draw text using the standard (non-OpenGL) android views. Not suitable for my purposes, since I wanted to be able to rotate the text labels in 3D.
- Use the spritetext sample code found in the android samples. Again, not suitable since the spritetext sample code renders the text using a method which draws to the viewport directly, thus using pixels, not world co-ordinates, and therefore the rendered text would not be rotatable in 3D.
- Draw the text to a canvas; render the canvas to a bitmap, then use this bitmap as an OpenGL texture just like any other. Ah! Now we're getting somewhere.
In addition, I realised that if I was to avoid having to create a huge number of textures (one for each text label I wanted to display) I would need a solution that used a texture atlas.
So without further ado, I present the following code, which provides:
- True 3D labels, allowing OpenGL scaling, rotation, and translation operations.
- A single texture to display any number of labels.
- Simple to use, and hopefully understand, allowing for easy extension.
- Reasonably performant - all of the hard math is done once, up-front.
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import javax.microedition.khronos.opengles.GL10;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.opengl.GLUtils;
import android.text.TextUtils;
public class GlTextLabel {
/** Half the width of a single texture target. Each target renders a single character. */
private static final float HALF_WIDTH = 0.4f;
/** The characters a text label can contain. Numeric labels were enough for my purposes. */
private static final String CHARSET = "0123456789.-";
/** Stores the texture itself. */
private static int sTexture = -1;
// 3 floats per vertex; 4 vertices; 4 bytes per float.
private static final ByteBuffer BYTE_BUFFER = ByteBuffer.allocateDirect(3 * 4 * 4);
static {
BYTE_BUFFER.order(ByteOrder.nativeOrder());
}
private static final FloatBuffer VERTEX_BUFFER = BYTE_BUFFER.asFloatBuffer();
static {
VERTEX_BUFFER.put(new float[]{-HALF_WIDTH, HALF_WIDTH, 0f,
-HALF_WIDTH, -HALF_WIDTH, 0f,
HALF_WIDTH, -HALF_WIDTH, 0f,
HALF_WIDTH, HALF_WIDTH, 0f});
VERTEX_BUFFER.position(0);
}
/** One FloatBuffer holds the texture info for a single character in CHARSET. */
private static FloatBuffer[] sTexBuffers;
private String mText;
public GlTextLabel(String text) {
setText(text);
}
@Override
public void draw(GL10 gl) {
if (TextUtils.isEmpty(mText)) {
return;
}
gl.glEnable(GL10.GL_TEXTURE_2D);
gl.glEnableClientState(GL10.GL_VERTEX_ARRAY);
gl.glEnableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
/**
* The magic happens here. Each character is rendered as a texture in
* the atlas to a single two-triangle fan.
*/
gl.glVertexPointer(3, GL10.GL_FLOAT, 0, VERTEX_BUFFER);
// Centre the text.
gl.glTranslatef(-HALF_WIDTH * mText.length() + HALF_WIDTH, 0, 0);
for (int i = 0; i < mText.length(); ++i) {
FloatBuffer tex_buffer = sTexBuffers[CHARSET.indexOf(mText.charAt(i))];
gl.glTexCoordPointer(2, GL10.GL_FLOAT, 0, tex_buffer);
gl.glDrawArrays(GL10.GL_TRIANGLE_FAN, 0,
VERTEX_BUFFER.capacity() / 3);
// Advance to the next character position.
gl.glTranslatef(2 * HALF_WIDTH, 0, 0);
}
gl.glDisableClientState(GL10.GL_VERTEX_ARRAY);
gl.glDisableClientState(GL10.GL_TEXTURE_COORD_ARRAY);
gl.glDisable(GL10.GL_TEXTURE_2D);
}
@Override
public void initialize(GL10 gl) {
if (sTexture == -1) {
initTexture(gl);
initTextureBuffers(gl);
}
}
private static void initTexture(GL10 gl) {
int color = (0x7f << 24) | (0xff << 16) | (0x00 << 8) | 0x00;
// The numbers may take some tweaking. This creates a 512x32px bitmap,
// which is enough to hold everything in CHARSET, but may need adjusting
// if you add characters to CHARSET. Note the dimensions must be powers of two, or the texture won't render.
Bitmap bitmap = Bitmap.createBitmap(512, 32, Bitmap.Config.ARGB_8888);
// Fully transparent background.
bitmap.eraseColor(GlUtil.RGBA(0xff, 0xff, 0xff, 0x00));
Canvas canvas = new Canvas(bitmap);
Paint paint = new Paint();
// Again you may need to play with the numbers here. These values display
// each character pretty much centred within its quad.
paint.setTextSize(30);
paint.setAntiAlias(true);
paint.setColor(color);
// Draw the characters to the texture atlas's canvas.
for (int i = 0; i < CHARSET.length(); ++i) {
canvas.drawText("" + CHARSET.charAt(i), 24 * i + 4, 27, paint);
}
// Fairly standard OpenGL texture setup code below.
gl.glEnable(GL10.GL_TEXTURE_2D);
gl.glEnable(GL10.GL_BLEND);
int[] textures = new int[1];
gl.glGenTextures(1, textures, 0);
sTexture = textures[0];
gl.glActiveTexture(GL10.GL_TEXTURE0);
gl.glBindTexture(GL10.GL_TEXTURE_2D, textures[0]);
gl.glBlendFunc(GL10.GL_SRC_ALPHA, GL10.GL_ONE_MINUS_SRC_ALPHA);
gl.glTexEnvf(GL10.GL_TEXTURE_ENV, GL10.GL_TEXTURE_ENV_MODE, GL10.GL_REPLACE);
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MIN_FILTER, GL10.GL_NEAREST);
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_MAG_FILTER, GL10.GL_LINEAR);
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_S, GL10.GL_REPEAT);
gl.glTexParameterf(GL10.GL_TEXTURE_2D, GL10.GL_TEXTURE_WRAP_T, GL10.GL_REPEAT);
GLUtils.texImage2D(GL10.GL_TEXTURE_2D, 0, bitmap, 0);
// Clean up.
bitmap.recycle();
}
private static void initTextureBuffers(GL10 gl) {
// Again, you'll need to play with the numbers a little if you change CHARSET.
// We need a texture buffer to hold co-ordinates for its character's position within
// the texture atlas. Texture co-ordinates range from 0.0 - 1.0, with 0.0 being the
// left-hand edge, and 1.0 being the right-hand edge of the entire atlas.
final float ratio = 240f / 512f;
// How large to make each texture buffer. 4 pairs of 2-float co-ordinates * 4 bytes per float.
final int size = 8 * 4;
int buffer_count = CHARSET.length();
sTexBuffers = new FloatBuffer[buffer_count];
for (int i = 0; i < buffer_count; ++i) {
ByteBuffer buf = ByteBuffer.allocateDirect(size);
buf.order(ByteOrder.nativeOrder());
sTexBuffers[i] = buf.asFloatBuffer();
float x1 = ratio * (i / buffer_count);
float x2 = ratio * (i + 1f) / buffer_count);
sTexBuffers[i].put(new float[]{x1, 0.0f,
x1, 1.0f,
x2, 1.0f,
x2, 0.0f});
sTexBuffers[i].position(0);
}
}
public void setText(String text) {
mText = text;
}
}
There are of course a few drawbacks with the code currently:
- The colour of each label is fixed; all labels will be in the same colour.
- The spacing between characters is fixed, and would be difficult to modify at render time. This gives labels a somewhat technical look that may not be suitable for some games, e.g.
- The text sizing is somewhat brittle and must be achieved largely through trial-and-error, although since the resultant label can be scaled via OpenGL, this is mostly a non-issue.
Comments welcome.