Sunday, January 30, 2011

Rendering Text In OpenGL

Having a need to render text to an OpenGL view, I did what any self-respecting coder does - see if anyone else had suffered the same problems, and documented their travails. I found a few promising leads, most of which offered some form of at least one of the following solutions:
  1. 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.
  2. 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.
  3. 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.
The last option seemed like it would do exactly what I needed. However, I could find no examples on how to achieve it - all mentions of the technique I found stopped short of actually providing code, leaving me with the task of coming up with a method for rendering a texture to a triangle strip (the android OpenGL implementation has no GL_QUADS or GL_POLYGON ability) using texture buffers, and allowing the flexibility to create different labels on the fly - i.e, without having a potentially lengthy texture co-ordinate calculation when a new label is created.

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.
However, for my purposes the simplicity and speed with which I can have proper 3D rendered labels greatly outweigh these restrictions. I hope others find it as useful as I have.

Comments welcome.