简介:这是一份面向Qt/C++开发者的连续图像快速显示示例工程,聚焦Qt控件在图像流场景下的性能差异与实现选择。内容围绕QLabel静态显示、QWidget重绘以及QOpenGLWidget硬件加速三条路径展开,涵盖OpenGL纹理上传、GLSL着色器渲染、连续帧刷新与FPS性能观察等关键点,适合视频播放、实时数据可视化等方向的中级学习者对照研读。压缩包共16个文件,约20.67MB,以4个cpp源文件、3个头文件和6张jpg示例图为主,另含ui界面文件、pro工程配置及user配置文件,便于直接打开工程理解模块组织。目前已有2833人学习。对于希望弄清CPU绘制与GPU加速显示差异、并获取可运行参考实现的读者,这份材料能提供较完整的工程线索和性能优化思路。
1. 从 QLabel 到 QOpenGLWidget:连续图像掉帧到底掉在哪
1080p、60fps 的相机流塞进QLabel::setPixmap,界面几秒钟就变成幻灯片,这不是玄学。QImage转QPixmap要在 GUI 线程上做格式转换,再叠加平台后端的 blit,1080p RGB888 一帧约 6MB,60fps 就是每秒 360MB 的纯拷贝量,单核直接打满。QLabel那条路径本质是「CPU 画图」,它没错,只是不适合高频刷新。换成 QT OpenGL 控件后,一帧图像就是一张纹理,通道转换、缩放、上屏全在 GPU 侧完成,CPU 只负责递一个内存地址。这一篇拆的就是这条连续图像显示路径:控件骨架怎么搭、纹理怎么传、着色器怎么写、刷新时哪里最容易翻车,适合做相机预览、视频回显、算法结果实时显示的人。
2. 选型与管线:QOpenGLWidget、纹理与着色器各自管什么
2.1 三条显示路径的真实代价
先把选型说清楚,不然后面的参数调起来没有参照系。做连续图像显示,Qt 里能走的路基本就三条,差别不在「能不能显示」,而在每一帧的搬运次数。
| 路径 | 每帧主要开销 | 帧率上限(1080p,普通办公本) | CPU 占用 | 适用场景 |
|---|---|---|---|---|
QLabel::setPixmap | QImage→QPixmap 转换 + 缩放 + 窗口重绘 | 25~30fps 开始明显掉帧 | 单核 60%~100% | 单张图、低频刷新、调试用 |
QGraphicsView+QGraphicsPixmapItem | 同上,外加场景图遍历 | 30fps 左右 | 单核 50% 以上 | 需要缩放、拖拽、图元叠加 |
QOpenGLWidget+ 纹理 | 一次内存到显存的上传 | 60fps 起,可到显示器刷新率 | 单核 5%~15% | 连续帧、实时预览、视频回显 |
差距的来源很直接:前两条路径每帧都要在 CPU 上把像素重新排一遍,再交给窗口系统合成;OpenGL 路径每帧只做「拷贝 + 一次 draw call」,像素的缩放和色彩转换交给显卡的固定管线或着色器。1080p 60fps 下,前者的瓶颈是内存带宽和 GUI 线程,后者的瓶颈通常只剩上传带宽和垂直同步。
我一般会先问一句:这个画面是不是每秒都在变?只要答案是「是」,并且分辨率超过 720p,就别在 QLabel 上挣扎了,换控件比调参数省时间。
2.2 一个能跑的最小骨架
QOpenGLWidget是 Qt 提供的 GL 渲染表面,它自己管理一个 FBO,paintGL里画的東西最终会被合成到窗口上。最小骨架只需要三个虚函数,加上一个线程安全的入帧接口。
// frameview.h #include <QOpenGLWidget> #include <QOpenGLExtraFunctions> #include <QImage> #include <QMutex> class FrameView : public QOpenGLWidget, protected QOpenGLExtraFunctions { Q_OBJECT public: explicit FrameView(QWidget *parent = nullptr); public slots: // 采集线程直接调这个,只做「保存最新帧 + 请求重绘」 void pushFrame(const QImage &img); protected: void initializeGL() override; void resizeGL(int w, int h) override; void paintGL() override; private: QMutex m_mutex; QImage m_pending; // 待上传的帧 bool m_hasNew = false; GLuint m_tex = 0; int m_texW = 0, m_texH = 0; GLuint m_program = 0; GLuint m_vao = 0, m_vbo = 0; };// frameview.cpp(关键片段) FrameView::FrameView(QWidget *parent) : QOpenGLWidget(parent) { // 只要新帧,不保留上一帧的缓冲,减少一次额外拷贝 setUpdateBehavior(QOpenGLWidget::NoPartialUpdate); } void FrameView::pushFrame(const QImage &img) { { QMutexLocker lock(&m_mutex); // 关键:必须深拷贝。采集端的 buffer 下一帧就会被覆盖 m_pending = img.copy(); m_hasNew = true; } update(); // 合并式重绘请求,同一轮事件循环里多次调用只触发一次 paintGL } void FrameView::initializeGL() { initializeOpenGLFunctions(); // 全屏四边形:两个三角形,位置 + UV 交错存放 static const float verts[] = { // x, y, u, v -1.f, -1.f, 0.f, 1.f, 1.f, -1.f, 1.f, 1.f, -1.f, 1.f, 0.f, 0.f, 1.f, 1.f, 1.f, 0.f, }; glGenVertexArrays(1, &m_vao); glBindVertexArray(m_vao); glGenBuffers(1, &m_vbo); glBindBuffer(GL_ARRAY_BUFFER, m_vbo); glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); glEnableVertexAttribArray(0); glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), nullptr); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), reinterpret_cast<void*>(2 * sizeof(float))); // 纹理只创建一次,尺寸变化时再重建(见 3.3) glGenTextures(1, &m_tex); glBindTexture(GL_TEXTURE_2D, m_tex); 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); } void FrameView::resizeGL(int w, int h) { glViewport(0, 0, w, h); // 视口跟着控件走,纹理不用动 }三个函数的职责要分清:initializeGL只跑一次,建 VAO、VBO、纹理、着色器程序;resizeGL只改视口,不要在这里重建纹理,这是显存泄漏的高发区;paintGL每帧调用,只做「取新帧 → 上传 → 画一个四边形」,任何格式转换、缩放、内存分配都不该出现在这里。pushFrame里的img.copy()看起来多余,实际上是最容易被省略、也最容易导致花屏的一行——采集端复用同一块内存时,不加拷贝就会看到撕裂的图像。
2.3 帧数据从采集线程到 GPU 的三段路
整条管线我习惯拆成三段看,每一段出问题的表现都不一样。
第一段是采集线程到共享缓冲。采集回调里拿到的是相机 SDK 的裸指针,或者解码器输出的QImage,这时要做的是「转成统一的像素格式 + 深拷贝一份」。格式统一放在这里做,不要在paintGL里做,否则每帧都在 GUI 线程上做一次格式转换,等于把 QLabel 的老路又走了一遍。常见做法是用一个容量为 2~3 帧的环形缓冲,写指针和读指针分开,满了就覆盖最老的一帧。
第二段是共享缓冲到 GUI 线程。pushFrame用update()而不是repaint(),前者只是打个标记,等事件循环空闲时合并成一次重绘;后者会强制同步重绘,采集频率一高就把 GUI 线程钉死。另外别用「每帧发一个 QueuedConnection 信号」的写法——信号本身没问题,问题是事件队列会随着采集频率膨胀,一旦 GUI 线程某一帧慢了,后面排队的帧全是过期数据,延迟越滚越大。
第三段是 GUI 线程到 GPU。paintGL里把最新一帧的像素上传成纹理,然后画一个覆盖整个控件的四边形。上传这一步的细节最多,第 3 章展开。
提示:判断瓶颈在哪一段,最简单的办法是在
paintGL首尾各打一次时间戳,连续统计 100 帧。如果paintGL本身很快但实际帧率上不去,问题多半在第二段的事件投递;如果paintGL单次超过 16ms,问题就在上传或绘制。
3. 纹理上传:把 QImage 喂给 GL 的正确姿势
3.1 像素格式映射:别让红蓝互换
QImage的格式名和 OpenGL 的枚举不是一一对应的,尤其是Format_RGB32和Format_ARGB32——在小端机器上,它们在内存里的实际字节序是 BGRA,而不是 RGBA。这个坑几乎每个人都会踩一次,表现是图像能显示,但人脸发蓝、天空发红。
| QImage 格式 | 每像素字节 | 正确的 GL 格式 | GL 类型 | 备注 |
|---|---|---|---|---|
Format_RGB888 | 3 | GL_RGB | GL_UNSIGNED_BYTE | 行跨距常不是 4 的倍数,必须处理对齐 |
Format_RGB32/Format_ARGB32 | 4 | GL_BGRA | GL_UNSIGNED_BYTE | 小端平台上的内存布局就是 BGRA |
Format_RGBA8888 | 4 | GL_RGBA | GL_UNSIGNED_BYTE | 字节序明确,最省心的格式 |
Format_Grayscale8 | 1 | GL_RED | GL_UNSIGNED_BYTE | 着色器里取.r复制到 rgb 三通道 |
Format_Grayscale16 | 2 | GL_R16 | GL_UNSIGNED_SHORT | 需要 GL 3.0 以上 |
选型建议很直接:如果格式能自己定,就定Format_RGBA8888,字节序无歧义,跨平台不用写条件分支;如果格式由相机 SDK 或解码器决定,那就按表映射,别硬转成Format_RGB888再上传——多一次转换就多一次全图拷贝,1080p 下这个转换本身就要几毫秒。
Format_Grayscale8还有个细节:GL_RED上传后,采样得到的是(r, 0, 0, 1),直接输出会是一张红图。要么在着色器里写vec3(texture(...).r),要么用glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_G, GL_RED)之类的纹理 swizzle 一次性配好,后者不占着色器指令。
3.2 stride 与对齐:宽度不是 4 的倍数就斜切
QImage::bytesPerLine()不等于width() * bytesPerPixel(),Qt 会把每行补齐到 4 字节对齐。OpenGL 默认的GL_UNPACK_ALIGNMENT也假定每行按 4 字节对齐,两者看起来「刚好一致」,但只要图像宽度乘通道数不是 4 的倍数,或者你自己做了一次裁剪,就会看到图像一行一行往右斜,像被剪切过一样。
正确的做法是把真实的行跨距显式告诉 GL,而不是让它自己猜。
void FrameView::uploadTexture(const QImage &img) { const int w = img.width(); const int h = img.height(); // 尺寸变化时才重建纹理存储,平时只做 SubImage 更新 if (w != m_texW || h != m_texH) { glBindTexture(GL_TEXTURE_2D, m_tex); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_BGRA, GL_UNSIGNED_BYTE, nullptr); m_texW = w; m_texH = h; } glBindTexture(GL_TEXTURE_2D, m_tex); // 1) 关闭 4 字节对齐假设,否则窄图会错行 glPixelStorei(GL_UNPACK_ALIGNMENT, 1); // 2) 行跨距:QImage 可能带 padding,必须按字节数告诉 GL const int bpp = img.depth() / 8; const int rowPitch = img.bytesPerLine() / bpp; // 单位是像素,不是字节 glPixelStorei(GL_UNPACK_ROW_LENGTH, rowPitch); glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, w, h, GL_BGRA, GL_UNSIGNED_BYTE, img.constBits()); // 3) 恢复默认值,避免影响后面别的纹理上传 glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); glPixelStorei(GL_UNPACK_ALIGNMENT, 4); }GL_UNPACK_ROW_LENGTH的单位是像素而不是字节,这一点比bytesPerLine()更容易搞混。传bytesPerLine() / bpp之后,GL 会自己算出每一行起点,然后跳着读。这个调用属于QOpenGLExtraFunctions而不是QOpenGLFunctions,如果你只继承了后者,编译会直接报未声明。
代码末尾的「恢复默认值」是我被坑过之后加的习惯:glPixelStorei是全局状态,不是纹理状态,一次没恢复,后面所有上传都会带着这个跨距,表现出来就是别的地方莫名其妙地错行。
3.3 纹理的创建与更新:glTexImage2D 与 glTexSubImage2D 别用反
glTexImage2D会给纹理重新分配显存,glTexSubImage2D只在已有存储上覆盖数据。连续图像场景里,尺寸一般不变,所以正确节奏是:首帧或者尺寸变化时调一次glTexImage2D,其余每一帧都调glTexSubImage2D。
我见过不少代码每帧都调glTexImage2D,功能上没问题,但帧率会莫名其妙比预期低一截——每次重新分配显存都会触发一次同步等待,驱动会等上一帧画完才允许释放旧存储,GPU 和 CPU 就这么互相等起来。改成SubImage之后通常能省下 1~3ms,1080p 下这个差距很实在。
还有一个隐式问题:纹理的 min filter 如果保持默认的GL_NEAREST_MIPMAP_LINEAR,而你又没生成 mipmap,采样结果会全黑。这个问题在「画出来一片漆黑」的排查清单里排第一,比着色器编译失败还常见。
3.4 PBO 双缓冲:把上传从同步变异步
glTexSubImage2D传一个主机指针时,驱动通常要把数据拷到临时缓冲区再 DMA 到显存,这一步对 CPU 来说是可阻塞的。1080p 60fps 下,这个拷贝本身不算大,但架不住它和渲染抢同一份带宽。
PBO(Pixel Buffer Object)的作用是把「主机内存 → 显存」这一步也交给驱动异步做:你把像素拷进一个显存里的缓冲区,glTexSubImage2D的最后一个参数传nullptr,表示「从当前绑定的 PBO 里取数据」。用两个 PBO 轮流,一帧在传、一帧在写,CPU 就不用等。
void FrameView::uploadViaPBO(const QImage &img) { const int w = img.width(), h = img.height(); const int bytesPerLine = w * 4; // 这里统一成紧密排列 const int total = bytesPerLine * h; // 双缓冲交替,避免等待上一帧传完 m_pboIndex = (m_pboIndex + 1) % 2; glBindBuffer(GL_PIXEL_UNPACK_BUFFER, m_pbo[m_pboIndex]); glBufferData(GL_PIXEL_UNPACK_BUFFER, total, nullptr, GL_STREAM_DRAW); void *dst = glMapBufferRange(GL_PIXEL_UNPACK_BUFFER, 0, total, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT); if (dst) { const uchar *src = img.constBits(); const int srcPitch = img.bytesPerLine(); // QImage 带 padding 时逐行拷,PBO 内必须是紧密排列 if (srcPitch == bytesPerLine) { memcpy(dst, src, total); } else { for (int y = 0; y < h; ++y) memcpy(static_cast<uchar*>(dst) + y * bytesPerLine, src + y * srcPitch, bytesPerLine); } glUnmapBuffer(GL_PIXEL_UNPACK_BUFFER); } // 关键:解绑 PBO 之前调用,最后一个参数必须是 nullptr glBindTexture(GL_TEXTURE_2D, m_tex); glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); // PBO 内已紧密排列 glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, w, h, GL_BGRA, GL_UNSIGNED_BYTE, nullptr); glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); }这段代码有两个必须注意的参数:GL_MAP_INVALIDATE_BUFFER_BIT告诉驱动「旧内容我不要了」,可以避免它为了保留数据而做一次额外拷贝;GL_STREAM_DRAW表示这块缓冲写一次、用一次,驱动会把它放在合适的内存区域。
PBO 的收益在 1080p 以上比较明显,720p 以下基本可以忽略,反而多了一次内存拷贝。我的经验是:分辨率超过 1280×720 且帧率要求 60fps 以上再上 PBO,否则直接glTexSubImage2D更省事——这也算一条血泪经验,早年在一个 640×480 的项目上硬上 PBO,性能没提升,代码复杂度倒是翻倍。
4. 着色器端:把格式转换和缩放交给 GPU
4.1 顶点着色器与全屏四边形
顶点着色器简单到几乎不用写,一个全屏四边形、两组属性(位置和 UV),把 UV 原样传给片元着色器即可。
// vertex.glsl #version 330 core layout(location = 0) in vec2 aPos; layout(location = 1) in vec2 aUV; out vec2 vUV; void main() { vUV = aUV; gl_Position = vec4(aPos, 0.0, 1.0); }这里唯一值得说的是 UV 的上下方向。OpenGL 的纹理原点在左下角,而QImage的第一行是图像顶部,所以要么在顶点数据里把 v 反过来(上面骨架里的0.f, 1.f就是这个意思),要么在片元里写1.0 - vUV.y。我偏向在顶点数据里处理,片元里少一条指令。
4.2 片元着色器:BGR 交换与 YUV 转换
BGR 交换是最常见的一步。如果上传时用的是GL_RGB但数据实际是 BGR,与其在 CPU 上转一遍,不如在片元里换一下通道顺序,代价是零。
// fragment_bgr.glsl #version 330 core in vec2 vUV; out vec4 fragColor; uniform sampler2D uTex; void main() { vec3 c = texture(uTex, vUV).rgb; fragColor = vec4(c.bgr, 1.0); // BGR -> RGB,一次 swizzle 搞定 }YUV 的情况稍微复杂一点,因为相机和视频解码器输出 YUV 是常态,而 CPU 端做 YUV→RGB 转换在 1080p 60fps 下要吃掉半核。把这步搬到 GPU,只需要两张纹理(Y 一张,UV 一张)和一段矩阵乘法。
// fragment_nv12.glsl #version 330 core in vec2 vUV; out vec4 fragColor; uniform sampler2D uTexY; // GL_RED,全分辨率 uniform sampler2D uTexUV; // GL_RG,半分辨率 uniform mat3 uYuvToRgb; // 色彩矩阵,由 CPU 侧按 BT.601/709 填入 void main() { float y = texture(uTexY, vUV).r; vec2 uv = texture(uTexUV, vUV).rg - vec2(0.5); // 有限范围(16~235)需要先做 (y - 16/255) * 255/219 的归一化 y = (y - 16.0 / 255.0) * (255.0 / 219.0); vec3 rgb = uYuvToRgb * vec3(y, uv.x, uv.y); fragColor = vec4(clamp(rgb, 0.0, 1.0), 1.0); }uYuvToRgb用 uniform 传矩阵而不是写死常量,是因为 BT.601 和 BT.709 的系数不同,不同来源的视频要用不同的矩阵,写死之后换一路信号源就得改着色器重编译。矩阵在 CPU 侧按标准算好,用glUniformMatrix3fv传进去,切换信号源时只改一个 uniform,成本几乎为零。
4.3 采样参数:filter、wrap、mipmap 三件套
这三个参数决定了「画质」和「性能」的取舍,配错了表现各异。
| 参数 | 可选值 | 连续图像场景的推荐 | 原因 |
|---|---|---|---|
GL_TEXTURE_MIN_FILTER | NEAREST/LINEAR/*_MIPMAP_* | GL_LINEAR | 不用 mipmap,避免每帧生成 mip 链的开销 |
GL_TEXTURE_MAG_FILTER | NEAREST/LINEAR | 预览用LINEAR,像素级检查用NEAREST | 放大时NEAREST能看清单个像素 |
GL_TEXTURE_WRAP_S/T | REPEAT/CLAMP_TO_EDGE | CLAMP_TO_EDGE | 边界采样不会串到对侧,避免边缘出现杂色 |
特别提醒 mipmap:如果 min filter 设成了带 mipmap 的枚举却没调glGenerateMipmap,纹理是不完整的,采样结果按规范是黑色。这个「黑匣子」现象排查起来很费时间,因为着色器编译通过、纹理也绑对了,就是不亮。
4.4 宽高比与 letterbox
控件尺寸和图像尺寸很少刚好一致,直接铺满会拉伸变形。正确做法是在顶点数据里按宽高比收缩,留出黑边(letterbox)。
// 根据控件宽高比和图像宽高比,算出四边形在 NDC 里的缩放 float viewAspect = float(width()) / float(height()); float imgAspect = float(m_texW) / float(m_texH); float sx = 1.0f, sy = 1.0f; if (imgAspect > viewAspect) { sy = viewAspect / imgAspect; // 图更宽,上下留黑边 } else { sx = imgAspect / viewAspect; // 图更高,左右留黑边 } // 把 sx、sy 作为 uniform 传进顶点着色器,乘到 aPos 上这一步放在顶点着色器里做,比在 CPU 上重新算一遍顶点数据要干净,控件 resize 时也只是更新一个 uniform,不用重建 VBO。
5. 避坑与排查:连续刷新下的五个翻车现场
5.1 每帧发一个队列信号,延迟越滚越大
现象:画面本身不卡,但操作有延迟,跑几分钟之后延迟从几十毫秒涨到一秒以上,重启又恢复正常。
原因:采集线程用Qt::QueuedConnection每帧发一个信号,GUI 线程处理不过来时事件队列持续膨胀,队列里排的都是过期帧,消费速度永远追不上生产速度。
解决:改成「共享最新帧 + 标志位」的模式,采集线程只覆盖一块缓冲并置位,GUI 线程按自己的节奏取。update()本身是合并的,同一轮事件循环里调一百次也只触发一次paintGL,天然具备丢帧能力。
5.2 在 paintGL 里做格式转换或混用 QPainter
现象:帧率只有预期的三分之一,用性能分析工具一看,热点全在paintGL里的convertToFormat或QPainter::drawImage。
原因:QPainter在QOpenGLWidget上绘制时,会先结束原生 GL 状态、切到 painter 的管线,再切回来,每次切换都有固定开销;格式转换更是纯 CPU 的全图拷贝。
解决:格式转换前移到采集线程,paintGL里只留 GL 调用。确实需要叠加文字或十字线时,用QPainter的beginNativePainting()/endNativePainting()包住原生 GL 部分,并且把 painter 的绘制合并到一次,不要每帧建一个QPainter对象。
5.3 resizeGL 里重建纹理,显存一路涨
现象:窗口拖动缩放几次之后显存占用翻倍,拖久了直接分配失败,画面变黑。
原因:在resizeGL里调glGenTextures生成了新纹理,但旧的没glDeleteTextures,句柄泄漏,显存跟着泄漏。
解决:纹理只跟图像尺寸绑定,跟控件尺寸无关,resizeGL里只调glViewport。确实要重建时,先glDeleteTextures(1, &m_tex)再glGenTextures,并且把m_texW/m_texH一起更新,避免下一帧又走一遍重建分支。
5.4 跨线程传 QImage 的隐式共享陷阱
现象:偶发花屏,图像上下两半来自不同帧,或者出现一条横向撕裂。
原因:QImage是隐式共享的,拷贝构造不复制数据。采集线程把QImage发出去之后继续复用同一块 buffer,接收端读到的就是正在被改写的内容。写时复制在这种情况下帮不上忙,因为两边都没有「写」这个动作被 Qt 感知到。
解决:跨线程传递时显式copy(),或者干脆在采集线程里维护一块自己的std::vector<uint8_t>加元数据,绕开QImage的共享语义。这一点没有后悔药,只能在设计阶段定好。
5.5 高 DPI 下的视口尺寸对不上
现象:在缩放比例 150% 的屏幕上,图像只占了控件左上角的一小块,其余是黑的。
原因:glViewport用的是设备像素,而width()/height()返回的是逻辑像素,两者差了devicePixelRatio倍。
解决:resizeGL(int w, int h)的参数已经是设备像素,直接用;如果在paintGL里自己设视口,写成width() * devicePixelRatioF()。跨平台项目里最好在初始化时打印一次这两个值确认,别假设它们相等。
6. 收在帧率上:用帧时间统计把 60fps 钉死
显示是否流畅,不能靠肉眼判断。我现在的习惯是:控件一写完,先加一段帧时间统计,把「采集帧率、上传耗时、绘制耗时、端到端延迟」四个数打出来,再谈优化。
// 在 paintGL 首尾各打一次时间戳,滑动窗口统计 void FrameView::paintGL() { QElapsedTimer t; t.start(); // ... 取帧、上传、绘制 ... m_frameTimes.append(t.nsecsElapsed() / 1e6); // 毫秒 if (m_frameTimes.size() > 120) m_frameTimes.removeFirst(); ++m_frameCount; if (m_statTimer.elapsed() >= 1000) { std::sort(m_frameTimes.begin(), m_frameTimes.end()); const double p50 = m_frameTimes[m_frameTimes.size() / 2]; const double p99 = m_frameTimes[int(m_frameTimes.size() * 0.99)]; qDebug() << "fps:" << m_frameCount << "paint p50:" << p50 << "ms" << "p99:" << p99 << "ms"; m_frameCount = 0; m_statTimer.restart(); } }看 p50 和 p99 而不是平均值,是因为卡顿从来不是均匀分布的。p50 反映常态,p99 反映最差的那 1%——用户感知到的「一顿」几乎都来自 p99,平均值再漂亮也掩盖不了。
下面这张表是我在不同配置下测出的大致区间,用来判断某个数字是不是异常:
| 指标 | 健康区间(1080p) | 异常时的第一嫌疑 |
|---|---|---|
paintGLp50 | 3~8ms | 超过 12ms 基本是同步上传在等驱动 |
paintGLp99 | 小于 16ms | 超过 30ms 一般是显存分配或纹理重建 |
| 端到端延迟 | 小于 50ms | 超过 100ms 检查队列是否在堆积 |
| CPU 单核占用 | 15% 以内 | 超过 40% 检查paintGL里有无内存分配 |
那次让我印象最深的是:一个 4K 的预览窗口,帧率始终卡在 30fps 上不去,p50 只有 5ms,看代码怎么都找不出问题。最后发现是垂直同步把帧率锁到了显示器刷新率的一半——窗口的合成和 GL 的交换对不齐,驱动干脆降半。把QSurfaceFormat::setSwapInterval(0)打开再测,帧率立刻上到 60,再根据实际需求决定要不要重新开同步。这件事之后,我每次接到「帧率怎么都上不去但代码看起来没问题」的活,都强制先关垂直同步跑一遍基线,再逐项往回加,省得在一堆正确代码里瞎猜。
渲染路径本身不复杂,难的是每一段都不出错:格式映射对了、行跨距传对了、纹理只在需要时重建、上传和绘制分开、统计指标看得懂。把这几件事做扎实,60fps 是个很朴素的结果。希望帮到你。
本文还有配套的精品资源,点击获取