简介:面向OpenGL开发者的汉字显示示例工程,解决中文字符在2D/3D图形场景中的渲染难题,适合游戏开发、科学可视化及虚拟现实等需要中文标注的开发者参考,对具有一定图形学基础、希望扩展文本渲染能力的读者尤为合适。压缩包共包含29个文件,以头文件、C++源文件和工程配置文件为主,涵盖GLFont核心模块、可执行演示程序、图标与工具栏资源,整体大小约98KB,目录结构紧凑,便于定位关键代码。目前已有432人学习/下载。示例工程通过FreeType库加载字体、将字形纹理化并配合着色器绘制,完整展示了3D文字从顶点变换到光照计算的实现路线,同时涉及混合函数、深度测试和字形缓存优化等细节,可帮助读者快速搭建OpenGL汉字渲染框架并理解常见性能优化策略,兼顾不同平台下的兼容性调整要点。 做图形应用时,一提到OpenGL,大家第一反应都是画三角形、贴纹理、调shader。可真到“给界面加几个汉字”这一步,你会发现OpenGL没有提供任何“画字”的函数,中文资料里讲得又多半是英文示例。我在一个数据可视化项目里要在三维场景中叠加中文标签,被字符编码、纹理图集、字形度量这几个环节来回折腾了一个多星期。这篇就把OpenGL显示汉字的完整方案整理出来:先讲清楚为什么难,再对比主流做法,给出可运行的Freetype动态纹理图集代码,最后把我踩过的坑和性能优化经验一并铺开。
1. 为什么OpenGL画个汉字这么费劲
1.1 OpenGL根本不提供“画字”的能力
OpenGL本质是一个光栅化API,它只认顶点、纹理、片元,不认识字符,更不会解析字体文件。你调用glDrawArrays画的是三角形,texture采样拿到的是像素颜色。至于屏幕上那个“汉”字长什么样,这个“字”如何从字体文件变成像素,OpenGL一概不管。DirectX也一样,微软后来搞的DirectWrite是显卡驱动之外的独立组件。
所以“OpenGL显示汉字”这个命题,本质上不是“让OpenGL认识汉字”,而是“自己想办法把汉字字形变成OpenGL能画的几何和纹理”。第一步就把很多从教材入门的人卡住了:我该用哪个API?答案是根本没有API,整个文本渲染链路都要自己搭。
1.2 汉字把字符渲染的复杂度拉满了
英文文本渲染之所以看起来简单,是因为字符集实在太小:26个小写、26个大写、10个数字,加上常见标点,总共一百来个字符。一次性把所有字形生成成一张纹理图集,运行时查表即可。
汉字就不是这个量级了。GB2312收录6763个汉字,GBK扩展到20902个,Unicode的CJK统一表意文字基础区有20992个码位,加上扩展A/B/C/D区,总数超过九万。如果按每个字形32x32像素来算,一张2048x2048的纹理图集大概能放4096个字,连GB2312都塞不下,更别提Unicode全集。字符集大直接改变了方案设计:你没法像英文那样“启动时全量生成”,必须考虑按需生成、图集管理、缓存淘汰。
字形本身的复杂度也不一样。英文字母笔画简单,12像素字号下仍可辨认;汉字笔画多、结构密集,字号一缩小就糊成一团。等宽、衬线、黑体、楷体,渲染细节差异极大。此外汉字还得和数字、英文、标点混排,行高、基线对齐、字间距这些度量处理不好,整段文字看起来就歪歪扭扭。
换句话说,能把汉字渲染做好,英文就是白送;但只按英文场景设计的轮子,拿到中文一定翻车。
2. 三种主流方案:静态位图、Freetype动态图集和SDF
解决“把字形变成像素”的路径主要有三条,没有绝对优劣,取决于你的使用场景。
2.1 静态位图字体:字符集合固定时的最优解
静态位图字体用BMFont、Hiero这类工具离线把指定字符集渲染成PNG图集,再生成一份.fnt描述文件,标出每个字符在图集里的坐标、宽高、偏移和步进。程序运行时解析.fnt,把PNG当OpenGL纹理加载,然后根据字符编码查找UV,画出对应的四边形。
这套方案的优点非常明确:运行时零依赖,不需要链接Freetype,不需要处理字体文件解析,性能也是三条路里最高的。适合字符集合完全可预知的场景,比如游戏主菜单、技能名称、按钮文字。局限也明显:玩家昵称、聊天输入、文本编辑器这类动态内容没法预生成。如果强行把GB2312全量字符都做进图集,PNG体积会很大,加载也不划算。
2.2 Freetype动态纹理图集:要做成产品就选它
Freetype是一个开源的字体光栅化库,负责把TrueType/OpenType字体里的矢量轮廓渲染成位图。运行时用Freetype加载系统字体文件,某个汉字第一次出现时动态生成字形,写入一张OpenGL纹理图集,后续再遇到同一个字直接查缓存。
这是目前游戏引擎UI、工具软件、图形界面里最通用的做法。优点:字符覆盖范围只受字体文件限制,中英文混排自然,内存占用可控,因为生成过的字才会进图集。代价是需要自己实现图集管理、UTF-8解码和度量换算。这套方案也是我本文要展开讲的重点。
2.3 SDF字体:想要缩放和特效再升级
Valve在《求生之路》里提出过一套有向距离场方案:不是直接存字形灰度,而是把每个像素到字形边缘最近距离存入纹理,shader里再用smoothstep重建边缘。好处是放大缩小都平滑,描边、阴影、发光效果做起来非常容易。
但汉字笔画密集,SDF生成时容易发生笔画间的距离场互相干扰,小分辨率下字形黏连、发虚的问题比英文字母严重得多。参数调校是个细致活。如果你的UI不需要极端缩放,也不必为特效折腾SDF,动态图集加shader里做点描边就够用了。
三种方案对比如下:
| 方案 | 运行时依赖 | 字符覆盖 | 内存/纹理 | 灵活性 | 适用场景 |
|---|---|---|---|---|---|
| 静态位图 | 无 | 受预生成限制 | 按字符集增大 | 低 | 固定菜单、数值 |
| Freetype动态图集 | Freetype库 | 任意字体支持字符 | 按需增长 | 高 | 动态文本、编辑器 |
| SDF | Freetype+距离场预计算 | 任意字体支持字符 | 分辨率低但精度要求高 | 中 | 缩放UI、特效文字 |
3. 实战:用Freetype把汉字渲染进OpenGL纹理图集
下面进入正题,我用C++和OpenGL 3.3实现一套最小可用的汉字渲染。为了篇幅,OpenGL上下文创建、加载函数库(glad/glew)这些基础步骤略过,直接从Freetype初始化开始。
3.1 初始化Freetype并加载中文字体文件
Freetype安装很简单,Windows下用vcpkgvcpkg install freetype,Linux下用系统包管理器装libfreetype-dev。字体文件方面,Windows推荐用微软雅黑msyh.ttc或黑体simhei.ttf,Linux可以用NotoSansCJK-Regular.ttc或文泉驿微米黑,macOS用PingFang.ttc。
#include <ft2build.h> #include FT_FREETYPE_H FT_Library g_ft = nullptr; FT_Face g_face = nullptr; bool initFont(const char* fontPath, int pixelSize) { if (FT_Init_FreeType(&g_ft)) { return false; } // 注意 .ttc 是字体集合,多个face可以共用轮廓数据 // face_index 传 0 通常能拿到默认字体 if (FT_New_Face(g_ft, fontPath, 0, &g_face)) { return false; } if (FT_Set_Pixel_Sizes(g_face, 0, pixelSize)) { return false; } return true; }这里有个细节:FT_Set_Pixel_Sizes第二个参数为0时表示只设置纵向像素尺寸,宽度由字形宽高比自动计算。中文方块字通常宽高接近1:1,这样设置没问题。字体文件路径如果包含中文,Windows下用宽字符路径,Linux下按UTF-8字节处理,不要用char直接做字符串拼接判断。
3.2 设计图集管理器和字形信息结构
我用一张OpenGL纹理当作图集,需要记录每个字形在图集中的UV、位图尺寸、偏移和步进。数据结构:
struct GlyphInfo { float u0, v0, u1, v1; // 最终OpenGL纹理坐标 int width, height; // 字形位图像素尺寸 int bearingX, bearingY; // 相对笔位置的偏移 int advanceX; // 水平步进 }; class GlyphAtlas { public: bool init(int cellWidth, int cellHeight); bool addChar(uint32_t codepoint); const GlyphInfo* getGlyph(uint32_t codepoint) const; private: GLuint texId_ = 0; int cellWidth_ = 0, cellHeight_ = 0; int cursorX_ = 1, cursorY_ = 1, rowHeight_ = 0; std::unordered_map<uint32_t, GlyphInfo> glyphs_; };初始化时分配一张全空纹理,内部格式用GL_R8,只存灰度值,符合字形图的特点,内存和带宽都比RGBA小得多。
bool GlyphAtlas::init(int cellWidth, int cellHeight) { cellWidth_ = cellWidth; cellHeight_ = cellHeight; glGenTextures(1, &texId_); glBindTexture(GL_TEXTURE_2D, texId_); glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, cellWidth_, cellHeight_, 0, GL_RED, GL_UNSIGNED_BYTE, nullptr); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); return true; }3.3 核心函数:动态生成字形并写入图集
加载某个汉字的流程:先用FT_Load_Char把Unicode码点解析成字形轮廓,再用FT_LOAD_RENDER模式直接得到抗锯齿灰度位图,然后把位图拷贝进图集纹理的空闲区域。
bool GlyphAtlas::addChar(uint32_t codepoint) { if (glyphs_.count(codepoint)) return true; if (FT_Load_Char(g_face, codepoint, FT_LOAD_RENDER)) { return false; // 字体中没有这个字形 } FT_GlyphSlot slot = g_face->glyph; FT_Bitmap& bmp = slot->bitmap; // 左右和上下都留2像素padding,防止线性采样串到相邻字形 const int pad = 2; int w = bmp.width + pad * 2; int h = bmp.rows + pad * 2; if (cursorX_ + w >= cellWidth_) { cursorX_ = 1; cursorY_ += rowHeight_ + pad; rowHeight_ = 0; } if (cursorY_ + h >= cellHeight_) { return false; // 图集放满了,后面的章节再处理 } glBindTexture(GL_TEXTURE_2D, texId_); glTexSubImage2D(GL_TEXTURE_2D, 0, cursorX_ + pad, cursorY_ + pad, bmp.width, bmp.rows, GL_RED, GL_UNSIGNED_BYTE, bmp.buffer); // Freetype位图原点在左上,OpenGL纹理坐标原点在左下 // 这里直接把左上管理坐标换算成OpenGL的v坐标 float u0 = (cursorX_ + pad) / (float)cellWidth_; float u1 = (cursorX_ + pad + bmp.width) / (float)cellWidth_; float v0 = 1.0f - (cursorY_ + pad + bmp.rows) / (float)cellHeight_; float v1 = 1.0f - (cursorY_ + pad) / (float)cellHeight_; glyphs_[codepoint] = { u0, v0, u1, v1, (int)bmp.width, (int)bmp.rows, slot->bitmap_left, slot->bitmap_top, (int)(slot->advance.x / 64) // Freetype的26.6定点数,右移6位 }; cursorX_ += w; rowHeight_ = std::max(rowHeight_, h); return true; }advance.x是Freetype的26.6定点数,表示当前字形绘制后笔位置前进的距离,需要除以64得到像素值。这是特别容易漏的一步,漏掉之后每个字都会叠在一起。
3.4 渲染字符串:从UTF-8到一组四边形
拿到字符串后,先要做UTF-8解码。因为std::string只是字节流,直接按下标遍历得到的是字节而不是字符。
std::vector<uint32_t> utf8ToCodepoints(const std::string& s) { std::vector<uint32_t> out; for (size_t i = 0; i < s.size();) { unsigned char c = (unsigned char)s[i]; uint32_t cp = 0; size_t extra = 0; if (c < 0x80) { cp = c; } else if ((c >> 5) == 0x06) { cp = c & 0x1F; extra = 1; } else if ((c >> 4) == 0x0E) { cp = c & 0x0F; extra = 2; } else if ((c >> 3) == 0x1E) { cp = c & 0x07; extra = 3; } else { i++; continue; } if (i + extra >= s.size() + 1) break; bool ok = true; for (size_t k = 1; k <= extra; k++) { unsigned char nc = (unsigned char)s[i + k]; if ((nc & 0xC0) != 0x80) { ok = false; break; } cp = (cp << 6) | (nc & 0x3F); } if (ok) out.push_back(cp); i += extra + 1; } return out; }这里我特意写了自己解码的版本,是为了把逻辑讲清楚。生产代码想省事可以用utf8cpp、ICU或C++20的std::u8string配合库处理。
然后遍历码点,对每个字符取出字形信息,计算四边形的四个顶点位置:
void renderText(GlyphAtlas& atlas, const std::string& text, float penX, float penY, float scale, std::vector<float>& vertices) { for (uint32_t cp : utf8ToCodepoints(text)) { if (!atlas.getGlyph(cp)) { atlas.addChar(cp); } const GlyphInfo& g = *atlas.getGlyph(cp); // penY 是基线位置,y轴向上 float x0 = penX + g.bearingX * scale; float y0 = penY + (g.bearingY - g.height) * scale; // 字形底部 float x1 = x0 + g.width * scale; float y1 = penY + g.bearingY * scale; // 字形顶部 // 两个三角形共6个顶点 float verts[] = { x0, y0, g.u0, g.v0, x1, y0, g.u1, g.v0, x1, y1, g.u1, g.v1, x0, y0, g.u0, g.v0, x1, y1, g.u1, g.v1, x0, y1, g.u0, g.v1, }; vertices.insert(vertices.end(), std::begin(verts), std::end(verts)); penX += g.advanceX * scale; } }着色器很简单,顶点着色器只做常规的MVP变换,片段着色器用图集纹理的红色分量当alpha,和文字颜色相乘:
#version 330 core layout(location = 0) in vec2 aPos; layout(location = 1) in vec2 aUV; uniform mat4 uProj; out vec2 vUV; void main() { gl_Position = uProj * vec4(aPos, 0.0, 1.0); vUV = aUV; }#version 330 core in vec2 vUV; uniform sampler2D uFontTex; uniform vec4 uColor; out vec4 FragColor; void main() { float alpha = texture(uFontTex, vUV).r; FragColor = vec4(uColor.rgb, uColor.a * alpha); }绘制时绑定图集纹理,绑定攒好的VBO,开混合glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA),一个数组纹理对应一个draw call。
3.5 图集放满了怎么办
动态图集最常见的问题是某次界面一次性展示大量文字,图集空间不足。最简单的做法是扩容:分配一张更大的新纹理,把已有字形重传一遍,然后重置光标继续放置。比如从2048扩到4096,常用汉字几十个其实根本用不到。
还可以做多图集:GlyphAtlas里持有一个std::vector<GLuint>,满了就新建一张纹理,渲染时按字形所在图集切换绑定。多图集适合超大字符范围或动态加载的框架,但每次切换纹理也是一次状态切换,攒批逻辑会变复杂。
我做项目时的经验是:先按2048x2048、字号32px预估,界面正常使用时通常只会有几百个不同汉字,占不满。真遇到极端情况,用扩容方案简单可靠,别把LRU和淘汰算法加进来,除非你明确知道性能瓶颈在图集空间。
4. 避坑记录:编码、坐标计算与字体回退
整个方案跑通之后,真正折磨人的往往是几个细节问题。
4.1 UTF-8解码:char数组不等于字符串
把std::string里的字符一个个取出来传给FT_Load_Char,这是新手最容易踩的坑。char在C++里是字节类型,一个汉字在UTF-8下占3个字节,直接传单个字节给Freetype,它会当成一个不存在的拉丁字符处理,结果要么渲染失败要么显示乱码。
我见过有人用glfwSetWindowTitle("中文")没问题,就误以为文本处理很简单。实际上窗口标题是系统API处理的,底层自己做了编码转换,OpenGL这边没有这种自动转换。建议在项目里统一约定:所有从文本控件、网络包、配置文件读进来的字符串,进入渲染层前一律先统一成UTF-8,再解码成码点。至于wchar_t,Windows下是16位宽,Linux下是32位宽,跨平台用它做中间格式早晚出事,最好别碰。
4.2 bearing、advance和y轴方向,五个度量值够你折腾一晚
Freetype的度量体系初看很绕:bitmap_left、bitmap_top、advance.x、metrics.horiBearingX、metrics.horiBearingY,还有字形位图宽高。先说advance.x,它是26.6定点数,必须右移6位才是像素。bitmap_left和bitmap_top是位图相对笔位置的偏移。
关键是坐标系方向。Freetype内部y轴向上,很多GUI框架的屏幕坐标y轴向下,如果直接用顶部原点投影,字形会上下颠倒。我的建议是:把正交投影固定成y轴向上,渲染文本时penY表示基线位置,那么字形底部是penY + (bearingY - height),顶部是penY + bearingY。这样从数学上最直观,不容易错。如果项目里用了y轴向下的投影,那就在生成顶点时整体翻转一次y,而不要试图去改Freetype的度量值。
中英文混排时还有个隐藏问题:不同字体文件的高度度量不一样。中文用雅黑、英文用Arial,光看height指标差不少,混排时基线会飘。我的处理办法是:以中文主字体为基准,英文数字也用同一个中文字体文件渲染,虽然英文字形没专业英文字体好看,但至少基线不会乱。接受不了这个妥协,再去做多字体基线对齐,那是另一套复杂度。
4.3 字体文件缺字形时的回退策略
真实项目里一定会遇到字体文件里没有的字形。比如黑体字库覆盖GB2312,遇到生
本文还有配套的精品资源,点击获取