OpenGL high quality text rendering

Posted by jerr0 on Tue, 28 Dec 2021 14:37:24 +0100

High Quality Text Rendering

preface

It is challenging to retain text of the highest possible quality in real-time 3D graphics. Objects can dynamically change their position, rotation, scale, and viewing angle. All of this has a negative impact on quality because text is usually generated only once, not in each frame. Depending on the font engine and its performance, it takes a long time to generate textures for the entire text. Usually, this time is enough to affect performance.

This document describes how to obtain the best text quality when the object is semi dynamic. Semi dynamic objects are objects that change neither often (not every frame) nor during animation time.

This example describes how to calculate the font size, which should closely match the texture pixels with the screen pixels.

We will use the font engine as part of Android. The font engine generates an RGBA image containing the entire text shape. Then upload the image to the texture, and then map the texture to the rectangle. The rectangle must have an appropriate aspect ratio defined according to the texture size.

Evaluate font size

To evaluate the font size of the object currently converted, we need to convert the four corners of the rectangle from 3D world space to 2D pixel screen space. The angle is expressed in pixels. We can calculate the distance between the two left corners and the distance between the two right corners. The average is then calculated from these distances. The average value is the value we are looking for, because this is the font size we will use to generate the image.

// 1. Use the current matrix to calculate the bounding box in the screen coordinates
Vector4f cLT = new Vector4f(-0.5f,-0.5f, 0.0f, 1.0f);
Vector4f cLB = new Vector4f(-0.5f, 0.5f, 0.0f, 1.0f);
Vector4f cRT = new Vector4f( 0.5f,-0.5f, 0.0f, 1.0f);
Vector4f cRB = new Vector4f( 0.5f, 0.5f, 0.0f, 1.0f);

// Instead of recalculating the matrix, we reuse the matrix that has been calculated for rendering. The update() method must be called after the render() method.
cLT.makePixelCoords(mMVPMatrix, theViewportWidth, theViewportHeight);
cLB.makePixelCoords(mMVPMatrix, theViewportWidth, theViewportHeight);
cRT.makePixelCoords(mMVPMatrix, theViewportWidth, theViewportHeight);
cRB.makePixelCoords(mMVPMatrix, theViewportWidth, theViewportHeight);

// 2. Evaluate the font size according to the height of the bounding box corner
Vector4f vl = Vector4f.sub(cLB, cLT);
Vector4f vr = Vector4f.sub(cRB, cRT);
textSize = (vl.length3() + vr.length3()) / 2.0f;

The following is the definition of the makePixelCoords method in the Vector4f class. This method converts 3D vertex positions to 2D pixel positions.

public void makePixelCoords(float[] aMatrix,
                            int aViewportWidth,
                            int aViewportHeight) {
  // To convert the vector to screen coordinates, we assume that the aMatrix is a modelview projection matrix
  // The transform method multiplies this vector by the aMatrix
  transform(aMatrix);
  
  // Convert to homogeneous coordinates
  x /= w;
  y /= w;
  z /= w;
  w = 1.0f;
  // Now the vector is normalized to the [- 1.0, 1.0] range
  
  // Convert to standardized device coordinates
  x = 0.5f + x * 0.5f;
  y = 0.5f + y * 0.5f;
  z = 0.5f + z * 0.5f;
  w = 1.0f;
  // The value is now limited to the [0.0, 1.0] range
  
  // Move coordinates to window space (in pixels)
  x *= (float) aViewportWidth;
  y *= (float) aViewportHeight;
}

Texture generation

Since we already know the font size, we can estimate the size of the target image. The image must be large enough to store the entire text without any clipping. On the other hand, it cannot be too large because the following geometric calculations are based on image size. We want to have an exact size that fits the content that the font engine will generate.

The height calculation is very simple because it is the size of the font, but the width is very complex. In order to calculate the width correctly, we need to use the font engine to help us estimate it. The Android Java SDK comes with the measureText method from the Paint object. Before measuring, we need to provide all necessary data to the object, such as font name, font size (we have calculated), anti aliasing, ARGB color (in our example, it is always white, because the shading may be completed later in the fragment shader), and other less important data.

Before we draw the text to the Bitmap object, we need to clear its contents with a completely transparent white ARGB = (0, 255, 255, 255). Use this color to clear the background and set the Paint color to white to prevent dark texturing that may occur due to alpha mixing. When it comes to blending, the GL blending function must be set correctly before rendering text. The blending function must be set to: glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA)

The following function completes all the steps mentioned above:

private void drawCanvasToTexture(
        String aText,
        float aFontSize) {
  
  if (aFontSize < 8.0f)
  aFontSize = 8.0f;
  
  if (aFontSize > 500.0f)
  aFontSize = 500.0f;
  
  Paint textPaint = new Paint();
  textPaint.setTextSize(aFontSize);
  textPaint.setFakeBoldText(false);
  textPaint.setAntiAlias(true);
  textPaint.setARGB(255, 255, 255, 255);
  // If hinting is supported, it needs to be enabled (uncomment the next line)
  // textPaint.setHinting(Paint.HINTING_ON);
  textPaint.setSubpixelText(true);
  textPaint.setXfermode(new PorterDuffXfermode(Mode.SCREEN));
  
  float realTextWidth = textPaint.measureText(aText);
  
  // Create a new bitmap with a width and height of 128 pixels
  bitmapWidth = (int)(realTextWidth + 2.0f);
  bitmapHeight = (int)aFontSize + 2;
  
  Bitmap textBitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888);
  textBitmap.eraseColor(Color.argb(0, 255, 255, 255));
  // Create a canvas that renders to bitmap
  Canvas bitmapCanvas = new Canvas(textBitmap);
  // Set the start drawing position to [1, base_line_position]
  // base_line_position may vary by font, but is usually equal to 75% of the font size (height).
  bitmapCanvas.drawText(aText, 1, 1.0f + aFontSize * 0.75f, textPaint);
  
  GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId[0]);
  HighQualityTextRenderer.checkGLError("glBindTexture");
  // Upload bitmap pixels to OpenGL textures
  GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, textBitmap, 0);
  // Release bitmap
  textBitmap.recycle();
  
  // After the image is uploaded to texture, the mipmap is regenerated
  GLES20.glGenerateMipmap(GLES20.GL_TEXTURE_2D);
  HighQualityTextRenderer.checkGLError("glGenerateMipmap");
}

Further optimization

  • If the text in the program changes frequently, this concept may be appropriate. We can create a separate thread that continuously updates the texture at regular intervals. In most cases, keep threads at the lowest possible priority, because generating text is always a time-consuming operation, which may lead to performance interruption. Updating the texture should be done on the thread that is the GL context.
  • If you render text along the Bezier curve or make some displacement, you need to estimate the font size more accurately. To do this, increase the rectangle width resolution. The rectangle is divided into vertical slices. The average height of all these slices was then evaluated. The average height value is used to improve the accuracy of font size.

Topics: OpenGL