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