[工程笔记]OpenGL 数字字体渲染异常考

事情是这样的:我在把一个模块从 Python 往 C++ 迁移。传感器热力图上面要叠一层文字标注------把每个 cell 的数值画在上面。Python 版本跑得好好的,C++ 版本一跑,数字直接歪了。

不是一般的歪。仔细看的话,每个数字的毛病还不一样:

1 ------竖线看起来像是波浪形的,一段粗一段细,最窄的地方几乎断掉,最宽的地方又莫名胖一圈。整体感觉这根竖线在"抖动",完全没有一个印刷体数字该有的干练。

4 ------竖线和横线交汇的那个角直接糊了。本来应该是一个锐利的直角,这边渲染出来是一小团色块,分不清横笔画的起止。斜线跟竖线接合处也毛毛糙糙,像是低分辨率的位图被暴力放大。

其他数字比如 23 当然也谈不上好看,但问题没 14 那么扎眼------因为笔画相对均匀的字形,糊一点还能忍。1 这种极窄字形和 4 这种带锐角交点的字形,是这种渲染瑕疵最要命的两个靶子。

诡异的是,我一行一行对着 Python 代码看过去,绘制逻辑一模一样------坐标怎么算的、字体怎么设的、颜色透明度,全都对得上。

同样的代码,不同的引擎

我开始怀疑是不是某个底层渲染状态不一样。于是把 Python 和 C++ 的渲染管线拆开画了一下。

Python 那边很简单:

复制代码
QPixmap (固定尺寸位图)
  └─ QPainter(pixmap)  → Raster paint engine
       └─ drawText()    → Windows GDI/ClearType 文字光栅化

拿一个 QPixmap,在上面创建一个 QPainter,文字走的是 Windows 系统自带的 GDI 光栅化。这玩意儿做文字渲染是专业的------ClearType 子像素抗锯齿一开,小字号也能打,该瘦的瘦该粗的粗,边缘干净利落。

C++ 这边呢:

复制代码
QOpenGLWidget::paintGL()
  ├─ Phase 1: QPainter(this) → GL paint engine
  │    └─ beginNativePainting() → 原生 GL 热力图渲染 → endNativePainting()
  └─ Phase 2: QPainter(this) → GL paint engine
       └─ drawText()          → OpenGL 纹理四边形文字渲染

我用的是 QOpenGLWidget------热力图本身用 OpenGL 画的,当然得用这个。那文字自然也在同一个 widget 上画,QPainter(this) 就完了。

但问题就出在这里。QPainter(this) 构造在 QOpenGLWidget 上,Qt 返回的 paint engine 是 GL paint engine,不是 Raster engine。这时候 drawText 走的是 OpenGL 纹理图集------Qt 把每个字形烘焙到一张 GL 纹理里,画文字就是画一个带纹理的四边形。

窄字形在这种方案下效果是灾难性的。1 在图集里就占几像素宽,映射到屏幕的时候精度不够,边缘各种 artifact。4 的交叉笔画也一个毛病。GDI/ClearType 完全没这问题,毕竟人家是操作系统层面专门干这活的。

还有个 DPI 的坑。Python 代码写的时候显式用了 devicePixelRatioF() 来创建物理分辨率 pixmap,C++ 版压根没管这事。

我当时想,不就是文字渲染引擎不对吗,换一下不就行了?

第一次修:修好一个,炸掉另一个

思路很直接:在 paintGL() 里先把文字画到一个 QImage 上------QImage 走的是 Raster engine,文字质量就没问题------然后再把这个 QImage 画到 GL 表面。

代码大概是这么写的:

cpp 复制代码
// Phase 2 改成:
QImage overlayImage(...);
QPainter p(&overlayImage);  // Raster 引擎,文字渲染正确了 ✓
p.drawText(...);
p.end();

QPainter widgetPainter(this);  // ← 又来一个 GL 引擎 QPainter!
widgetPainter.drawImage(...);  // ← GL 引擎绘制 QImage

文字确实好了,看着舒服。但扭头一看,热力图直接崩了------多设备模式下 cell 的纹理花花绿绿,完全没法看。

我人傻了。我就把 Phase 2 的文字改成 raster 再合成回去,跟 Phase 1 的 GL 热力图八竿子打不着啊?

根因:两个 QPainter(this) 在同一个 paintGL() 里打架

把 paintGL() 里的 painter 对象画出来就清楚了:

复制代码
QPainter glPainter(this)      // GL engine session #1
  → beginNativePainting → GL heatmap → endNativePainting
  → destructor

QPainter widgetPainter(this)  // GL engine session #2
  → drawImage (QImage → GL texture → quad)
  → destructor

两个 QPainter(this),各自初始化自己的 GL paint engine,各自有自己的纹理绑定、混合模式、shader 状态。第一个画完热力图析构了,但 GL 状态没有完全恢复到 Qt 期望的"干净"状态。第二个 painter 上来又搞了一通自己的 GL 操作,析构时同样清不干净。残留的 GL 状态直接污染了下一帧的 paintGL(),热力图纹理就花掉了。

多设备模式下尤其惨烈------设备数量上去以后纹理频繁切换,状态污染的后果被放大了。

其实 Qt 文档提过这事:QOpenGLWidget 的 QPainter 混用 native GL 的时候,要用 beginNativePainting/endNativePainting 包起来。但文档没强调的一点是------或者说我想当然地忽略了------在 paintGL() 里创建多个 QPainter(this) 是不安全的。每个 painter 独立初始化销毁自己的 GL engine 状态,彼此之间不是"接力"而是"互踩"。

正确的修法

核心思路:整个 paintGL() 里只留一个 QPainter(this)。

cpp 复制代码
void paintGL() {
    glClear(...);

    // ① 准备 raster 叠加层(纯 CPU,不碰 GL)
    {
        QImage overlayImage(physSz, QImage::Format_ARGB32_Premultiplied);
        overlayImage.setDevicePixelRatio(dpr);
        overlayImage.fill(Qt::transparent);
        QPainter p(&overlayImage);  // ← Raster engine (QImage)
        // ... 画所有文字:数值标注、中心十字线等 ...
    }  // p 析构,QImage 里已经有 GDI 渲染的高质量文字

    // ② 单一 QPainter 会话,一杆子到底
    QPainter painter(this);  // ← 唯一的 GL paint engine

    // Phase 1: native GL 热力图
    painter.beginNativePainting();
    // ... 热力图 draw call ...
    painter.endNativePainting();

    // Phase 2: 叠加层合成(还在同一个 painter 里)
    painter.drawImage(QRect(0, 0, width(), height()), overlay_cache_);

}  // painter 析构时统一清理 GL 状态

Raster 内容在外面先准备好------纯 CPU 操作,QImage 上的 QPainter 和 GL 一点关系没有。然后唯一的一个 QPainter(this) 接管一切:先画热力图,再 drawImage 把文字叠上去。一个 painter 负责所有 GL 操作,析构时统一还原整个 GL 状态,不会留烂摊子。

DPI 的事也顺手修了------QImage 用 size() * devicePixelRatioF() 的物理分辨率创建,setDevicePixelRatio(dpr) 让 QPainter 按逻辑坐标画,高 DPI 屏上文字终于不糊了。

为什么 GL 引擎画字就是不如 Raster?

要解释前面说的那些症状------1 波浪形、4 交角糊、笔画粗细不均------得先搞清楚 Qt GL 引擎是怎么渲染文字的。

Qt GL 引擎用的技术叫多通道有符号距离场(MSDF)。 简单讲,每个字形被预先烘焙到一张纹理里,纹理里存的不是颜色,而是"距离字形边缘有多远"的场数据。渲染的时候拿这个场数据去查,判断当前片元在字形内还是外,就能重建出字形的轮廓。

这个方案省显存、放缩不变形,本来是个好方案。但它有两个要命的短板:

短板一:窄字形纹素不够。 1 在字母表里差不多是最窄的字形了。MSDF 纹理里这种字形可能就分到两三个纹素的宽度。距离场在这种尺度下没法精确编码一个像素级的锐利边缘------解出来的轮廓会带上场的量化误差。表现出来就是竖线粗细不恒定,像波浪一样一段鼓一段瘪。开篇说的 1 抖来抖去就是这个原因。

短板二:锐角重建失败。 4 的竖线、横线、斜线在一点交汇,形成两个锐角。MSDF 描述这种结构需要的场信息密度远远高于直线段,小字号下纹理分辨率撑不住------交汇点的场数据精度不够,角度解出来是钝的,甚至两条线直接"断开"。开篇说的 4 那坨糊掉的角就是从这里来的。

短板三:没有 hinting。 GDI 光栅化的时候有个叫 hinting 的机制------说白了就是把笔画边界强行吸附到像素网格上。一个 10pt 的 1,hinting 之后竖线宽度就是整好 1 像素,边缘利落得像刀切。GL 引擎压根不做这事,笔画边界可能落在 1.3 像素的位置,出来的就是一整条 2 像素宽的模糊条,怎么都不清爽。

这三条加在一起,窄的抖、尖的糊、粗的毛------就是我在屏幕上看到的样子。

反过来看 Raster 引擎:

  • Raster 引擎 (QImage/QPixmap) :文字交给操作系统光栅化模块,Windows 上是 GDI + ClearType。GDI 自带 hinting 把笔画对齐到像素网格,ClearType 在 LCD 上做 RGB 子像素抗锯齿。1 该多宽就多宽,干净一根线;4 的锐角尖得能戳人。小字号下它就是王。

所以严格来说,不是 Qt 的 GL 文字渲染有多烂,而是"拿通用图形管线跟操作系统级文字渲染比"本身就不公平。术业有专攻嘛。

以后怎么避开

这坑踩完,我给自己定了三条规矩:

  1. QOpenGLWidget 的 paintGL() 里只搞一个 QPainter(this)。需要 native GL 和 2D 绘制混着来没问题,全部塞在同一个 painter 里,用 beginNativePainting/endNativePainting 切进去切出来就行。

  2. 文字渲染优先走 Raster 引擎。先画到 QImage 或 QPixmap 上(纯 CPU,文字质量有保证),再用 drawImage/drawPixmap 合成到 GL 表面。几十个 cell 的标注画下来,CPU 根本不带喘的。

  3. 高 DPI 屏别偷懒,显式处理 devicePixelRatioF()。QImage 用物理像素尺寸创建,setDevicePixelRatio 对齐逻辑坐标,不然在高分屏上永远糊给你看。

实际改动就两个地方:

  • pixmap_widget.h:334-336 --- 加了 overlay_cache_ (QImage) 和 last_cache_size_ (QSize) 两个成员
  • pixmap_widget.cpp:761-815 --- 重构 paintGL(),合并成单一 QPainter 会话 + raster 文字缓存
相关推荐
05候补工程师14 小时前
【线性代数·全体系复习】核心考点与解题方法论总结(考研/期末冲刺硬核干货)
笔记·线性代数·考研·矩阵
万物皆字节16 小时前
【记录】安装nodejs,miniconda后本地设置
笔记
智者知已应修善业16 小时前
【51单片机按键加减最大60显示0不再增加减到0不再减】2023-11-18
c++·经验分享·笔记·算法·51单片机
lunzi_082616 小时前
【学习笔记】《Python编程 从入门到实践》第1章:Python环境搭建与Hello World(完整版)
笔记·python·学习
问心无愧051316 小时前
ctf show web 入门173
数据库·笔记·sql·mysql
w20180016 小时前
四年级下册语文第七单元作文:我的“自画像”
笔记
咸甜适中17 小时前
rust语言学习笔记Trait(十)PartialOrd、Ord(大小比较)
笔记·学习·rust
小+不通文墨17 小时前
在树莓派中用*C语言*实现MQTT通信
c语言·经验分享·笔记·嵌入式硬件·学习
嵌入式×边缘AI:打怪升级日志17 小时前
PIR 人体红外控制板载 LED — 保姆级笔记
笔记