基于字体轮廓的GPU文字渲染
本文介绍了一种实现文字渲染的新方法。此算法的主要思路在于,将字体轮廓传输到片元着色器当中,在GPU中对文字进行渲染。因此得名于“基于字体轮廓的GPU文字渲染”。
优势如下
- 高质量:在任意缩放比例下,仍然能够保持良好的抗锯齿
- 高效率、低内存
对比:十万字符
- 基于三角网绘制:稳定在90帧左右,显存占用2.5GB
- 基于字体轮廓的GPU文字渲染:稳定在140帧左右,显存占用0.1GB
代码:geodoer/gpu-font-rendering: 基于矢量轮廓的GPU文字渲染 (github.com)
算法概述¶
主要思想如下
- 将字符的轮廓统一存储在一个buffer当中,并传输到渲染管线当中
- 将每个像素简化为圆形窗口,我们只需计算这个圆被字体的覆盖率
Coverage
即可(而这一步是在片元着色器当中做的) - 最后将
文字颜色*Coverage
作为片元颜色,这又可能达到抗锯齿的效果
算法步骤¶
以绘制I'm MM.
为例
准备字符轮廓¶
当遇到新字符时,则需要准备该字符的轮廓,之后需要传输到渲染管线当中,在片元着色器中使用。
准备三角网¶
绘制时,需要根据字符的排版准备三角网(详情请看后文)。一个字符需用一个长方形来覆盖,一个长方形打散成两个三角形。
准备顶点数据¶
顶点数据需要包含哪些信息?
- 顶点的世界坐标无需赘述,这里使用的是二维的坐标,你也可以扩展成三维的
- UV坐标的物理意义,之后再展开细聊
bufferIndex
其实即是此字符所对应的BufferGlyph
,不过传输的是array<BufferGlyph>
中的下标。根据此,片元着色器就能取到此片元所属字符的轮廓
struct BufferVertex {
float x, y; //顶点的世界坐标,可以是三维的
float u, v; //UV坐标
int32_t bufferIndex; //此字符所对应的BufferGlyph(array<BufferGlyph>的下标)
};
UV的物理意义¶
如下图所示,字体轮廓(即BufferCurve
)是有一个字体坐标系的,BufferCurve
中的点都是此坐标系的。
因此,我们需要标识,此顶点对应到字体坐标系上的坐标是多少,而这个信息就存在顶点的UV当中。
顶点着色器¶
顶点着色器很简单
- 顶点坐标做MVP变化即可
- UV坐标,一样输出到片元着色器即可,渲染管线会自动帮你插值
- 然而
bufferIndex
就要注意了,bufferIndex
是不允许被插值的,因此要用flat
来申明
#version 330 core
uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;
layout (location = 0) in vec2 vertexPosition;
layout (location = 1) in vec2 vertexUV;
layout (location = 2) in int vertexIndex;
out vec2 uv;
flat out int bufferIndex;
//flat声明一个标量或向量变量是“平面的”(即不会被插值)
void main() {
gl_Position = projection * view * model * vec4(vertexPosition, 0, 1);
uv = vertexUV;
bufferIndex = vertexIndex;
}
片元着色器¶
片元着色器是GPU文字渲染的关键,其主要思路是
- 据bufferIndex获取字形描述
- 根据字形描述,拿到该字符的所有轮廓曲线
- 遍历轮廓曲线,计算轮廓对片元的覆盖率
- 最后,
片元颜色 = 覆盖率*文字颜色
#version 330 core
//字形描述
struct Glyph {
int start, count; //Curves中的索引
};
//二次贝塞尔曲线
struct Curve {
vec2 p0, p1, p2;
};
uniform isamplerBuffer glyphs; //字形缓冲纹理(存储所有的字形描述)
uniform samplerBuffer curves; //曲线缓冲纹理(存储所有字形的贝塞尔曲线)
uniform vec4 color; //字体颜色
in vec2 uv;
flat in int bufferIndex;
out vec4 result;
// 根据索引,从glyphs中加载Glyph
Glyph loadGlyph(int index) {
Glyph result;
ivec2 data = texelFetch(glyphs, index).xy;
//texelFetch直接从缓冲区中取出像素值,不进行插值处理
result.start = data.x;
result.count = data.y;
return result;
}
// 根据索引,从curves中加载Curve
Curve loadCurve(int index) {
Curve result;
result.p0 = texelFetch(curves, 3*index+0).xy;
result.p1 = texelFetch(curves, 3*index+1).xy;
result.p2 = texelFetch(curves, 3*index+2).xy;
return result;
}
// 计算覆盖率
float computeCoverage(vec2 p0, vec2 p1, vec2 p2) {
//...计算覆盖率,并赋值给alpha
return alpha;
}
void main() {
float alpha = 0;
//1. 根据bufferIndex获取字形描述
Glyph glyph = loadGlyph(bufferIndex);
//2. 遍历该字符的所有轮廓曲线
for (int i = 0; i < glyph.count; i++) {
Curve curve = loadCurve(glyph.start + i);
//一条二阶贝塞尔曲线
vec2 p0 = curve.p0 - uv;
vec2 p1 = curve.p1 - uv;
vec2 p2 = curve.p2 - uv;
//3. 计算覆盖率
alpha += computeCoverage(p0, p1, p2);
}
alpha = clamp(alpha, 0.0, 1.0);
result = color * alpha;
}
至于如何计算覆盖率,本文不再展开,可阅读参考链接中的文档。
文字编排¶
如上文“准备三角网”中所说,我们要根据String的情况,对单个字符进行编排,计算出三角网。
我们在生成文字轮廓buffer时,需要将每个字符的规格给记录下来,以便在编排文字时使用
- 在字符描述信息中,加上字符规格相关的属性
// 字符的描述信息
struct Glyph
{
FT_UInt glyphIndex; //可由FT_Get_Char_Index获得
int32_t bufferIndex; //在m_GlyphBuffer中的索引
int32_t curveCount; //该字形的曲线个数
//此字符的规格
// Important glyph metrics in font units.
FT_Pos width, height;
FT_Pos bearingX;
FT_Pos bearingY;
FT_Pos advance;
};
- 在生成文字轮廓buffer时,赋值这些属性
glyph.width = _face->glyph->metrics.width;
glyph.height = _face->glyph->metrics.height;
glyph.bearingX = _face->glyph->metrics.horiBearingX;
glyph.bearingY = _face->glyph->metrics.horiBearingY;
glyph.advance = _face->glyph->metrics.horiAdvance;
- 在绘制String时,只需要根据以上参数,编排字符串即可,具体可参照
font.cpp
中,Font
类,void draw(float x, float y, const std::string& text)
函数