OpenGL窗口文字渲染实战:从GDI到FreeType纹理图集
做Windows图形开发的人迟早会遇到一个绕不开的问题怎么在OpenGL窗口里把文字画上去。OpenGL本身不管文字它只管点、线、三角形和纹理所有文字渲染都得自己想辙。我最早做CAD/GIS类工具的时候需要在三维视图上标注地块编号、坐标信息当时第一反应就是用GDI因为Windows下GDI画文字实在太方便了CreateFont、DrawText一把梭几个API就能出字。但真把GDI和OpenGL混在一起用各种奇怪问题就接踵而来文字突然被覆盖、背景变成一块白底、缩放之后模糊得一塌糊涂、中文变成一排问号。这篇文章就是我折腾GDI和OpenGL文字绘制大半年的一个总结主要聊聊方案的取舍、坑点在哪、以及最终沉淀下来的可复用做法。适合刚接触OpenGL文字渲染的开发者也适合正在被中文明乱码或者高清屏DPI缩放折磨的人。1. 为什么在OpenGL窗口里画文字总绕不开GDI1.1 GDI文字绘制的基本操作与经典流程GDI全称Graphics Device Interface是Windows提供的二维图形接口。想用GDI画文字核心思路是拿到一个设备上下文DC往里面选入字体然后调用TextOut或者DrawText把字符串画进去。一个最基础的流程是// 1. 获取窗口DC HDC hdc GetDC(hWnd); // 2. 创建字体 HFONT hFont CreateFont( -24, // 字高负数表示按字符高度计算 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, LMicrosoft YaHei ); // 3. 选入DC并保存旧字体 HGDIOBJ hOldFont SelectObject(hdc, hFont); // 4. 设置背景透明否则文字会带一个底色方框 SetBkMode(hdc, TRANSPARENT); SetTextColor(hdc, RGB(255, 255, 0)); // 5. 绘制 RECT rect {10, 10, 300, 100}; DrawText(hdc, LHello GDI, -1, rect, DT_LEFT | DT_TOP); // 6. 恢复并清理 SelectObject(hdc, hOldFont); DeleteObject(hFont); ReleaseDC(hWnd, hdc);这套流程的优点非常明显接口成熟、参数直观、不用管字体解析和字形光栅化。创建一个HFONT指定字体名和大小系统会自动完成字体匹配、字形选择、抗锯齿渲染这些脏活。在纯二维窗口程序里GDI文字是绝对的主流比如MFC程序里的按钮文本、标签控件底层全是GDI在顶着。正因为它简单很多人会习惯性地想在OpenGL窗口上直接调这么一套代码觉得“反正都是画在窗口上”。这个想法的坑得从GDI和OpenGL的渲染机制说起。1.2 在OpenGL窗口中混用GDI的兼容性问题第一个问题是坐标系不一致。GDI的坐标原点在窗口客户区左上角y轴向下为正OpenGL默认原点在左下角y轴向上为正。你把一个在GDI坐标下算好的文字位置直接丢给OpenGL去对应画出来的文字位置要么上下颠倒要么偏移到一个想象不到的地方。有些项目用glOrtho把OpenGL的投影矩阵也设成左上角原点能缓解这个问题但视口、纹理坐标这些东西又容易跟着错。第二个问题是渲染时序。OpenGL的绘制流程是先发出绘制命令最终由驱动在合适的时机真正提交到显存和帧缓冲很多API调用属于“异步提交”。也就是说窗口上已经调用了SwapBuffers但这帧OpenGL内容到底画没画完程序并不一定能立刻感知。如果在这中间用GDI往窗口DC上叠文字很可能发生这样的情况GDI文字先画上去了然后OpenGL后台缓冲的内容一交换把文字整个盖掉或者反过来文字画完在屏幕上闪烁了一帧下一帧就被OpenGL的背景色清掉了。第三个问题是颜色格式和透明通道。GDI绘制文字默认使用当前DC的文字颜色和背景颜色。在OpenGL窗口里DC的背景色往往和OpenGL清屏色不匹配于是文字周围会多出一块明显的底色方块。唯一的解决办法是开头就调用SetBkMode(hdc, TRANSPARENT)但很多人第一次混用的时候根本想不到这层看到白底、黑底第一反应是纹理问题排查半天。第四个问题隐藏得更深GDI在位图GDI bitmap上画文字和直接在窗口DC上画文字走的是两条完全不同的路径。直接在窗口DC上绘制的文字本质上是绕过OpenGL管线由Windows窗口系统合成到屏幕上的。这意味着它不受OpenGL的模型视图矩阵、投影矩阵、裁剪区域控制。你想让文字跟着3D物体一起旋转缩放用GDI根本做不到。这一点对真正需要3D标注的开发场景是致命的。1.3 什么情况下GDI方案反而够用不能因为上面这些问题就把GDI全盘否定。GDI方案在我的实际项目里还是留下了位置。如果你只是想在OpenGL窗口的一个固定角落显示帧率、坐标信息、版本号不想让这些文字参与3D变换那GDI其实是成本最低的方案。只要别在SwapBuffers之后立刻画画之前把背景设为透明并且把绘制区域限定在窗口的一个固定矩形内工作得很好。我现在的做法是主视图的3D标注全部走OpenGL纹理方案窗口顶部的状态栏、工具提示、异常信息这些“UI层文字”直接用GDI叠在OpenGL之上。这样两种技术各干各擅长的部分冲突最少。另外还有一种扭转方案是先用GDI把文字画到一张内存位图上再把位图内容作为纹理上传到OpenGL这是从GDI过渡到OpenGL方案的一座桥后面会展开说。2. OpenGL文字绘制的方案选型位图字体、纹理图集还是矢量渲染2.1 简单快速的wglUseFontBitmaps方案要不要彻底脱离GDI对这个问题的探索顺序基本代表了OpenGL文字渲染的演进路线。第一个让我感到“哦原来OpenGL也能画字”的方案是wglUseFontBitmaps。这个函数是WGL扩展里的一个实用接口它的工作方式是先把当前DC里选好的GDI字体转换成一系列位图字形然后把这些字形编译成OpenGL显示列表。以后每当你需要画某个字符只需要调用glCallLists按字符索引调用对应的显示列表即可。一个典型的初始化代码长这样// hdc必须是OpenGL上下文关联的DC字体已经被SelectObject进去 wglUseFontBitmaps(hdc, 0, 256, 1000); glListBase(1000); // 绘制一行字符串 const char* text Hello OpenGL; glRasterPos2f(x, y); glCallLists(strlen(text), GL_UNSIGNED_BYTE, text);当时的感受是真香代码量比FreeType那套少了一个数量级而且字符间距、换行位置全部由GDI字体度量自动算好不需要自己管。但是用久了问题就冒出来了。首先它生成的是一张张位图颜色是在生成时就写死的想动态变色基本没门只能在绘制时配合glPixelTransfer、glColor等去折腾效果还很差。其次位图字体是固定分辨率放大之后马赛克严重。第三它依赖显示列表OpenGL 3.2之后的Core Profile直接砍掉了显示列表导致整个方案在现代OpenGL上下文下没法用。第四中文字体包含的字形数量大一条字符串两百个汉字就要生成两百个显示列表上下文切换和存储开销都不低。所以我的结论是wglUseFontBitmaps适合快速原型、调试信息输出、英文数字为主的场景不适合做产品级渲染更不适合做动态中文字体。2.2 成熟可用的FreeType 纹理图集方案彻底解决文字渲染问题我最终选择了FreeType 纹理图集这也是目前OpenGL文字方案里最主流、最通用的做法。核心思路一句话就能说清楚用FreeType库把字形Glyph光栅化为灰度位图把很多个字形打包到一张大纹理里绘制每个字符时从这张纹理里抠出对应字形贴到一个四边形上。整个流程可以拆成下面几步每一步都有容易踩的坑。第一步初始化FreeType并加载字体文件。FT_Init_FreeType拿到库句柄然后FT_New_Face加载一个字体文件。注意中文字体文件通常是TTC格式比如msyh.ttc这种文件里可能包含多个字体需要一个face index参数去选择你要的那个字体实例。加载失败的时候先别急着怀疑代码优先确认路径尤其是带中文路径的字体文件很多库在底层解析路径时对宽字符支持不好。第二步设置像素大小。FT_Set_Pixel_Sizes(face, 0, pixelSize)这个接口把字体缩放成指定像素高度。很多人在这一步直接把pixelSize设成自己想要的“字号”结果在高DPI显示器上文字小得可怜原因就是没有换算物理像素密度。这个问题后面专门讲。第三步逐个渲染字形。对每个字符调用FT_Load_Char实际上会执行字符索引查找、字形轮廓加载、位图渲染三个动作。核心代码是// 获取字形索引 FT_UInt glyphIndex FT_Get_Char_Index(face, codepoint); if (glyphIndex 0) { // 当前字体不包含这个字符需要考虑字体回退 return; } // 加载并渲染为灰度位图 FT_Load_Glyph(face, glyphIndex, FT_LOAD_DEFAULT); FT_Render_Glyph(face-glyph, FT_RENDER_MODE_NORMAL); FT_Bitmap* bitmap face-glyph-bitmap; // 此时bitmap-buffer里就是宽高为bitmap-width、bitmap-rows的灰度数据这里有一个很多人容易搞混的点字形度量信息不只是位图的宽高还包括缓冲区大小、左右边距、水平间距。FreeType里这些数据分布在face-glyph-bitmap、face-glyph-metrics、face-glyph-advance等多个字段里初学者很容易漏掉advance导致所有字符挤在一起。第四步把字形位图拷贝进图集纹理。先在初始化时分配一张足够大的纹理用glTexImage2D分配空间或者用glTexStorage2D更规范。然后对每个新字形调用glTexSubImage2D把灰度位图拷贝到纹理的指定位置。灰度数据要扩展成RGBA像素格式用GL_RED或GL_LUMINANCE也可以只要shader里能对应上就行。图集满了是大概率事件尤其是中文这种几千个常用字的场景。我的做法是初始化时直接给2048x2048并且实现一个简单的“分页”机制一张图集满了就再申请一张新纹理。渲染时给每个字符记录它所在纹理的ID避免混用纹理导致状态切换出错。第五步渲染。每个字符对应一个四边形把图集里的字形抠出来贴上。这部分的shader很简单顶点shader负责把字符的屏幕位置算出来片元shader从纹理采样灰度值再乘以一个textColor的uniform变量。之所以说这套方案能动态变色就是因为颜色是在片元阶段乘上去的和字体位图本身无关。2.3 预生成Bitmap Font与运行时文本渲染的取舍在FreeType基础上还有一条变通路线预生成Bitmap Font。也就是用工具或者一次性脚本把常用字符集全部渲染到一张PNG纹理里同时导出一个描述每个字符位置和度量的元数据文件运行时只加载这张纹理和元数据不再依赖FreeType。这个方案的优点是运行时的开销极小加载速度快尤其适合嵌入式、游戏里的固定字体、词云这类文本内容基本确定的场景。缺点是字符集一旦固定新文本就显示不出来了。如果你只是做一个固定中文菜单这个方案足够如果用户输入任意字符串那只能回到动态渲染方案。从实现复杂度和最终效果来看动态FreeType图集其实没有比预生成方案复杂多少主要多出来的就是把“离线生成一张图”变成“运行时按需往图集里塞字形”。如果项目里已经引入了FreeType我建议直接上动态图集省掉预生成工具链的维护成本。很多游戏引擎里的UI文字模块底层基本也是这个套路只是封装层更厚。3. 实操细节与经验从“能画”到“画得好”3.1 坐标系、DPI与字体度量画出来总歪的根源纹理图集方案画出来的文字歪歪扭扭十有八九是坐标系和字体度量没搞清楚。先把坐标系理清楚。OpenGL窗口坐标为左下角原点窗口客户区左上角为原点但窗口管理器在通知鼠标位置时用的是左上角原点。如果你想在鼠标点击的位置叠加一个文字标签必须先把窗口坐标转换成OpenGL坐标。最简单的办法是拿到窗口客户区高度用clientHeight减去窗口坐标y再转换到你的投影坐标系里。字体度量这块FreeType的字段含义值得花时间搞清楚。每个字形都有一个advance代表水平方向前进的距离也就是这个字符占多宽、下一个字符从哪个x开始画。同时还有bearingX、bearingY代表字形相对基线的偏移。绘制一行文字时基线是统一的y坐标每个字符从左到右排列。如果把所有字符都当成“从包围盒左上角开始画”而不去管baseline和bearing那混排英文、数字、中文时基线一定是乱的。我踩过最狠的一个坑是DPI缩放。Windows 10/11默认会对高DPI显示做缩放如果你的程序没有声明自己支持DPI感知系统会把整个窗口虚拟化为按96 DPI布局然后进行缩放渲染。结果就是你在150%缩放的屏幕上设置字号为24像素Windows先把窗口布局按12像素计算再放大OpenGL里的纹理却还是24像素两者一叠加文字位置和大小全乱。解决思路是程序启动时声明DPI感知。最彻底的是Per-Monitor V2方式每个监视器单独处理DPI变化。代码上就一句SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)但这意味着所有坐标计算都必须自己处理缩放工作量确实上来了。如果只是想快速解决问题至少在WM_CREATE里加一个SetProcessDPIAware()然后再把字体像素大小按GetDpiForWindow(hwnd) / 96.0f换算一下。这一步做完高分屏下文字发虚的问题能解决掉一半。3.2 中文、编码与字体回退文字全是问号怎么解决中文文字渲染是另一个大坑尤其涉及外部输入数据时问题反复出现。我见过最典型的例子是有人用CASS绘制宗地图时图纸上的文字全变成问号。这听上去像GIS软件的问题本质上还是字体和编码不匹配。OpenGL文字渲染里问号通常有两个原因一是字符串在编码转换时变成了无法识别的字符二是字体文件里根本没有对应的字形。先说编码。Windows内部统一使用UTF-16你打开一个文件拿到的是UTF-8字节流必须用MultiByteToWideChar转换转换时指定好代码页。很多代码默认传入CP_ACP然后系统按当前系统区域设置解码一旦系统区域是英文UTF-8中文就会变成一堆问号。正确做法是明确传CP_UTF8并且确保缓冲区长度算对。这块还有个隐藏问题有些字符串文件里带BOM有些没有转换前最好统一处理掉BOM否则第一个字符会多出一个\uFEFF。再说字体回退。FreeType加载的字体文件只包含这个字体里的字形。如果加载的是英文系统中自带的Arial.ttf那中文自然全找不到。即使加载了中文字体遇到生僻字也可能缺字形。我的做法是维护一个字体回退列表首选“Microsoft YaHei”如果FT_Get_Char_Index返回0依次尝试“SimHei”、“Noto Sans CJK SC”等字体依次生成字形。这个列表甚至可以做成配置项让UI层灵活切换。另外Windows下多国语言环境还牵扯一个issue同一套程序在中文系统、日文系统、英文系统下预置的中文字体文件名可能不同。更稳妥的做法不是硬编码字体名而是通过系统API获取当前语言的默认字体名再动态加载字体文件或者直接使用系统字体目录下已安装的字体用FT_New_Memory_Face把文件内容读入内存再解析。内存加载还有个好处字体文件可能被其他进程占用直接读文件流反而更稳定。3.3 性能优化把几千个文字的绘制成本压下去如果你的文字只在状态栏显示性能问题基本不存在。但当你在三维场景里需要给每个地块、每个测量点、每栋建筑都叠加文字标注时一次画面可能上千个文字这时候性能瓶颈就来了。最常见的性能杀手是重复绑定状态。每个字符都单独调用一次glBindTexture、glUseProgram、glBufferData再绘制一个四边形那一帧要几千次状态切换。正确做法是把同一纹理下的所有字符四边形顶点打包到一个大的VBO里一次性上传然后用一次glDrawElements/glDrawArrays画完。顶点的颜色信息可以统一由shader的uniform传入如果一个画面里有不同颜色的标注就按颜色分组每组颜色一次批量绘制状态切换从几千次降到几十次。更进一步可以把一整行静态文本缓存成一个文本对象。比如某个分析结果的标签文字内容不变、位置不变那就没必要每帧重新生成顶点。字符串内容作为key把生成的VBO、纹理ID、顶点数缓存起来下次直接重绘。文本的绘制频繁程度远高于内容变更频率这个缓存收益极大。还有一块容易被忽略的开销FreeType的FT_Render_Glyph速度并不快它要把字形轮廓轮廓光栅化成位图。如果是动态文本相同字形的渲染结果其实是一样的只取决于字号和抗锯齿模式所以字形数据也要加缓存。一个标准做法是在图集管理器里维护一个hashmapkey是字体名字号字符编码value是图集坐标和度量信息。第一次遇到某字符才渲染一次之后全部走命中缓存这样动态文本再长性能也能压在可接受范围内。4. 常见问题与排查经验速查4.1 高频问题与定位思路把两套方案都折腾过一遍之后我把自己和其他人常踩的坑整理成一个速查表按照“现象 - 可能原因 - 处理办法”的格式列在下面。问题现象可能原因处理办法OpenGL窗口里GDI文字画完就被覆盖或闪烁渲染时序不对SwapBuffers和GDI绘制冲突把GDI绘制放到OpenGL渲染前或直接改用离屏位图纹理方案文字周围出现一块白色/黑色底色方框DC使用了不透明背景模式在GDI绘制前调用SetBkMode(hdc, TRANSPARENT)文字在非100%缩放下变模糊或位置错乱程序未感知DPI缩放启动时声明DPI感知并按照实际DPI换算字体像素大小中文全部变成“??”字符串编码转换错误用MultiByteToWideChar并显式指定CP_UTF8处理BOM后再转换中文变成一行“口”字形方块当前字体不包含对应字形检查font path和FT_Get_Char_Index返回值配置字体回退列表FreeType加载字体文件失败路径带中文/字体格式不支持/TTC索引错误用宽字符路径API加载文件或改为FT_New_Memory_Face从内存读入TTC文件手动指定face index文字挤成一团字符间距混乱漏掉了advance或bearing计算渲染每个字符时累加advance不要只靠位图宽高wglUseFontBitmaps在OpenGL 3.2环境不工作Core Profile不支持显示列表检查上下文版本和兼容性标志如必须使用该方案可创建兼容性上下文大量文字绘制时卡顿明显每个字符单独提交draw call批量上传所有字符顶点统一绘制对静态文本做VBO缓存文字颜色无法改变始终是固定颜色位图字体方案颜色烘焙在位图里改用纹理图集方案片元shader里用uniform颜色相乘4.2 我在项目里的一套调试方法排查文字渲染问题时最容易瞎猜的地方是“觉得是FreeType渲染错了”还是“OpenGL绘制方式错了”。我摸索出一套有效率的调试顺序分享出来。第一步先确认字形本身。把FreeType产出的bitmap数据直接用调试工具导成PNG或者写到一个临时数组里检查像素值。如果一个字符的位图都是空的怎么调OpenGL都没用。我一般会写一个临时的调试函数把当前字形渲染完后写到BMP文件肉眼确认字形轮廓没问题再往纹理图集里放。第二步是确认图集区域。新建一个黑色的全屏四边形把图集纹理用简单shader整体贴出来看字形在纹理里是否重叠、是否越界。图集打包算法如果不好或者字符突然增加导致坐标冲突视觉上能看到明显的交错残影。确认图集布局没问题再进入实际字符绘制调试。第三步是逐行打印度量信息。每次加载一个字形就把它的advance、bearingX、bearingY、宽高、图集UV坐标打出来对照FreeType文档逐项核对。等所有字符的打印数据都符合预期了基线问题、间距问题一般也就消失了。初学阶段别嫌这步麻烦它管用的程度超出想象。一些收尾的经验之谈折腾完GDI、wglUseFontBitmaps、FreeType纹理图集这么一轮之后我自己项目里的最终选择是窗口外层的静态UI和调试信息继续用GDI叠在OpenGL之上因为代码量小、够稳定所有三维场景内的标注、动态生成的标签一律走FreeType动态图集。纹理图集方案的学习曲线确实比GDI陡但换来的是字体可自由旋转缩放、颜色随便调、中英文混排稳定可控这些收益对交互型图形软件来说是不可替代的。如果后续还要做复杂文本排版比如阿拉伯文从右向左、泰文的上下叠加符号光有图集也不够还得在前面加一层文字整形库比如HarfBuzz这又是另一个深坑了等哪天填完再写一篇。