做Windows图形开发的人,迟早会遇到一个绕不开的问题:怎么在OpenGL窗口里把文字画上去。OpenGL本身不管文字,它只管点、线、三角形和纹理,所有文字渲染都得自己想辙。我最早做CAD/GIS类工具的时候,需要在三维视图上标注地块编号、坐标信息,当时第一反应就是用GDI,因为Windows下GDI画文字实在太方便了,CreateFont、DrawText一把梭,几个API就能出字。但真把GDI和OpenGL混在一起用,各种奇怪问题就接踵而来:文字突然被覆盖、背景变成一块白底、缩放之后模糊得一塌糊涂、中文变成一排问号。这篇文章就是我折腾GDI和OpenGL文字绘制大半年的一个总结,主要聊聊方案的取舍、坑点在哪、以及最终沉淀下来的可复用做法。适合刚接触OpenGL文字渲染的开发者,也适合正在被中文明乱码或者高清屏DPI缩放折磨的人。
1. 为什么在OpenGL窗口里画文字总绕不开GDI
1.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, L"Microsoft 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, L"Hello 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速度并不快,它要把字形轮廓轮廓光栅化成位图。如果是动态文本,相同字形的渲染结果其实是一样的,只取决于字号和抗锯齿模式,所以字形数据也要加缓存。一个标准做法是在图集管理器里维护一个hashmap,key是字体名+字号+字符编码,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,这又是另一个深坑了,等哪天填完再写一篇。