OpenGL 高质量文本渲染
前言
在实时 3D 图形中保留尽可能高质量的文本具有挑战性。对象可以动态地改变它们的位置、旋转、比例和视角。所有这些都会对质量产生负面影响,因为文本通常只生成一次,而不是在每一帧中生成。根据字体引擎及其性能,为整个文本生成纹理需要很长时间。通常,这段时间足以影响性能。
本文档介绍了如何在对象为半动态时获得最佳文本质量的方法。半动态对象是既不经常(不是每帧)也不在动画时间内更改的对象。
此示例描述了如何计算字体大小,这应该使纹理像素与屏幕像素紧密匹配。
我们将使用作为 Android 一部分的字体引擎。字体引擎生成包含整个文本形状的 RGBA 图像。然后将图像上传到纹理中,然后将纹理映射到矩形上。矩形必须具有根据纹理大小定义的适当宽高比。
评估字体大小
要评估对象当前转换的字体大小,我们需要将矩形的四个角从 3D 世界空间转换为 2D 像素屏幕空间。以像素为单位表示角,我们可以计算两个左角之间的距离和两个右角之间的距离。然后根据这些距离计算平均值。平均值就是我们要查找的值,因为这是我们将用于生成图像的字体大小。
// 1. 使用当前矩阵计算屏幕坐标中的边界框
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);
// 我们重用已经为渲染计算过的矩阵,而不是再次计算矩阵。update() 方法必须在 render() 方法之后调用
cLT.makePixelCoords(mMVPMatrix, theViewportWidth, theViewportHeight);
cLB.makePixelCoords(mMVPMatrix, theViewportWidth, theViewportHeight);
cRT.makePixelCoords(mMVPMatrix, theViewportWidth, theViewportHeight);
cRB.makePixelCoords(mMVPMatrix, theViewportWidth, theViewportHeight);
// 2. 根据边界框角的高度评估字体大小
Vector4f vl = Vector4f.sub(cLB, cLT);
Vector4f vr = Vector4f.sub(cRB, cRT);
textSize = (vl.length3() + vr.length3()) / 2.0f;
下面是 Vector4f 类中 makePixelCoords 方法的定义。该方法将 3D 顶点位置转换为 2D 像素位置。
public void makePixelCoords(float[] aMatrix,
int aViewportWidth,
int aViewportHeight) {
// 将向量转换为屏幕坐标,我们假设 aMatrix 是 ModelViewProjection 矩阵
// transform 方法将此向量乘以 aMatrix
transform(aMatrix);
// 转换为齐次坐标
x /= w;
y /= w;
z /= w;
w = 1.0f;
// 现在向量标准化到了 [-1.0, 1.0] 范围
// 转换为标准化设备坐标
x = 0.5f + x * 0.5f;
y = 0.5f + y * 0.5f;
z = 0.5f + z * 0.5f;
w = 1.0f;
// 现在值被限制到 [0.0, 1.0] 范围
// 将坐标移动到窗口空间(以像素为单位)
x *= (float) aViewportWidth;
y *= (float) aViewportHeight;
}
纹理生成
由于我们已经知道字体大小,我们可以估计目标图像的大小。图像必须足够大以存储整个文本而无需任何剪切。另一方面,它不能太大,因为以下几何计算是基于图像大小的。我们希望有一个精确适合字体引擎将要生成的内容的大小。
高度计算很简单,因为是字体的大小,但是宽度非常复杂。为了正确计算宽度,我们需要使用字体引擎来帮助我们估计它。 Android Java SDK 附带来自 Paint 对象的 measureText 方法。在测量之前,我们需要向对象提供所有必要的数据,例如:字体名称、字体大小(我们已经计算过)、抗锯齿、ARGB 颜色(在我们的例子中它总是白色,因为着色可能是稍后在片段着色器中完成),以及其他不太重要的数据。
在我们将文本绘制到 Bitmap 对象之前,我们需要使用完全透明的白色 ARGB = (0, 255, 255, 255) 清除其内容。使用此颜色清除背景并将 Paint 颜色也设置为白色,可以防止可能因 alpha 混合而出现的暗纹素。说到混合,GL 混合函数必须在渲染文本之前正确设置,混合函数必须设置为:glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA)
下面的函数完成了上面提到的所有步骤:
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);
// 如果支持 hinting,需要启用(取消注释下面一行)
// textPaint.setHinting(Paint.HINTING_ON);
textPaint.setSubpixelText(true);
textPaint.setXfermode(new PorterDuffXfermode(Mode.SCREEN));
float realTextWidth = textPaint.measureText(aText);
// 创建一个新的 bitmap,宽高为128像素
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));
// 创建一个渲染到 bitmap 的画布
Canvas bitmapCanvas = new Canvas(textBitmap);
// 将开始绘图位置设置为 [1, base_line_position]
// base_line_position 可能因字体而异,但通常等于字体大小(高度)的 75%。
bitmapCanvas.drawText(aText, 1, 1.0f + aFontSize * 0.75f, textPaint);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId[0]);
HighQualityTextRenderer.checkGLError("glBindTexture");
// 上传 bitmap 像素到 OpenGL 纹理
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, textBitmap, 0);
// 释放 bitmap
textBitmap.recycle();
// 图像上传到 texture 后,重新生成 mipmap
GLES20.glGenerateMipmap(GLES20.GL_TEXTURE_2D);
HighQualityTextRenderer.checkGLError("glGenerateMipmap");
}
进一步优化
如果程序中的文本经常更改,那么这个概念可能适合。我们可以创建一个单独的线程,它以一定的间隔连续更新纹理。在大多数情况下,将线程保持在尽可能低的优先级,因为生成文本始终是耗时操作,有可能导致性能中断。更新纹理应该在为 GL 上下文的线程上完成。
如果沿贝塞尔曲线渲染文本或进行一些位移,则需要更精确地估计字体大小。为此,增加矩形宽度分辨率。此时矩形将被分成垂直切片。然后评估所有这些切片的平均高度。平均高度值用于提高字体大小的精度。