- 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.