简介:需要在Qt中实现高效图像交互显示的开发者,可参考这份围绕QOpenGLWidget与QImage结合的完整实现方案。内容覆盖从纹理创建、QImage像素数据上传到缩放、旋转、平移等矩阵变换的核心环节,并以可运行工程形式展示initializeGL、paintGL重写及鼠标键盘交互逻辑,适合有一定Qt/C++基础并期望进阶OpenGL渲染的读者。压缩包共10个文件,包含3个cpp源文件、2个h头文件、1个ui界面文件、1个qrc资源文件、1个pro工程文件及1张样例图片,整体约347KB,工程结构清晰,便于直接打开研读和二次开发。目前已有783人学习,资源以小型完整项目交付,既能帮助理解QImage纹理化显示流程,也可作为图像查看器或绘图应用的功能起点。
1. 用 QOpenGLWidget 托管 QImage:把大图拖出六十帧的显示层改造
之前接手一个看病理切片的显示模块,图像单张动辄三四千乘三四千像素,用 QLabel 直接塞 QPixmap,鼠标拖一下要等两百毫秒,缩放一次卡半秒。后来把显示层整体换到 QOpenGLWidget,QImage 仍然作为像素源,但渲染交给了 GPU,同样的操作降到十几毫秒以内,帧率翻了一个数量级。这套代码实现的就是这样一件事:用 QOpenGLWidget 显示 QImage 图片,支持滚轮缩放、鼠标拖拽平移、按角度旋转,整个交互过程全部走 OpenGL 渲染管线。适合在 Qt 里做图像查看、切片浏览、比对标注的开发者,拿去做基础显示层,比自己从零封装省非常多事。
2. 技术选型与架构设计:为什么显示层不选 QLabel 也不用 QGraphicsView
2.1 需求拆解:缩放、旋转、平移本质是矩阵变换
先别急着写代码。这个项目表面上要做四件事:显示、缩放、旋转、平移。细拆下去,会发现四件事都可以收敛到「图像坐标到窗口坐标」的映射关系上。显示可以看作缩放系数是 1.0、旋转角是 0 度、平移量为 0 的一种特殊变换;缩放是改 scale 标量;旋转是改 rotation 角度;平移是改 offset 偏移。也就是说,只要维护一个 4×4 变换矩阵,把图像的四个顶点坐标从图像空间变换到裁剪空间,四个功能就都齐了。
这里有一个值得注意的设计取舍:是像 QLabel 那样要求「整张图重新解码、重新缩放后合成到控件像素上」,还是只让 GPU 在绘制阶段读纹理。前者在 CPU 上做插值,图片分辨率一大就卡;后者所有插值和裁剪都由显卡完成,CPU 在鼠标事件里只是改几个数字。这个项目走的是后者,这也是它的核心价值。
另外,缩放中心也决定了手感。常见做法是「以鼠标当前位置为锚点缩放」:滚轮向上,光标指着的那个图像点保持不动,图像整体变大;滚轮向下同理。实现上需要在缩放前后把光标下的图像坐标换算出来,并反向补偿 offset。这个逻辑放在滚轮事件里做,后面第三章会给具体换算代码。
2.2 QOpenGLWidget 渲染管线与 QImage 的上屏路径
QOpenGLWidget 接管了一个原生窗口,并暴露三个关键虚函数:initializeGL() 在 OpenGL 上下文第一次准备好时调用,等于给你一个安全创建纹理、着色器和缓冲对象的时机;resizeGL() 在窗口尺寸变化时调用,用来更新视口和投影矩阵;paintGL() 是每一帧的绘制入口,所有绘制指令都在这里发出。
QImage 本身不是 GPU 资源,不能直接被着色器采样。它和 OpenGL 之间隔着一层「纹理上传」。项目里的做法是:在 initializeGL 中创建纹理对象,setImage 时把 QImage 的像素缓冲区提交给纹理;之后 paintGL 每帧就是绑定纹理、绑定 VAO、上传 uniform 矩阵、发出 glDrawArrays。整体上屏路径可以概括成两条线:
数据准备:QImage 像素数据 → 转换成 RGBA8888 格式 → 上传到纹理缓冲。 绘制阶段:矩阵参数 → 顶点着色器 → 片元着色器 → 帧缓冲 → 控件表面。
这个过程里 CPU 的工作量几乎不随图像分辨率变化,瓶颈在纹理上传和显存带宽,而不是 CPU 插值,所以大图拖动也不容易卡。相比 QGraphicsView 里用 QPixmap 做 transform,这套方案在缩放连续变化时的平滑度明显更好,因为绘制全程发生在显存里,不涉及 QPixmap 反复重新生成。
2.3 源码目录结构与类职责划分
一轮拆解之后,工程被分成三块:入口和应用框架、图像渲染控件、主窗口胶水代码。拿到源码后先看目录就能对上:
| 文件 | 职责 |
|---|---|
| main.cpp | 设置 OpenGL 版本与 QSurfaceFormat,启动应用 |
| mainwindow.h/cpp | 菜单、打开图片、快捷键等 UI 胶水 |
| opengl_image_widget.h/cpp | 核心显示控件,承载纹理、着色器、矩阵与鼠标交互 |
| shaders/simple.vert / simple.frag | 顶点与片元着色器 |
| CMakeLists.txt | 构建配置,声明 Qt 模块依赖 |
模块间的依赖很薄:MainWindow 只负责给 OpenGLImageWidget 传 QImage;OpenGLImageWidget 不关心图片从哪来,只负责渲染。这个边界在二次开发时很重要,哪怕后面要把图片源换成视频流,只要最终能产出 QImage 或某种纹理数据,显示控件几乎不用动。
3. 核心模块拆解:纹理上传、着色器与矩阵变换的实现链路
3.1 QImage 转 OpenGL 纹理:初始化与上传的两种写法
先看纹理初始化。这里用 QOpenGLTexture 封装原始 GL 调用,省去大量 glGenTextures、glBindTexture、glTexParameter 的样板代码。注意,这段代码必须放在 initializeGL 里执行,因为 OpenGL 上下文只在那一刻之后才安全可用。
// opengl_image_widget.cpp void OpenGLImageWidget::initializeGL() { // 创建 2D 纹理对象 m_texture = std::make_unique<QOpenGLTexture>(QOpenGLTexture::Target2D); m_texture->setMinificationFilter(QOpenGLTexture::Linear); // 缩小用线性过滤 m_texture->setMagnificationFilter(QOpenGLTexture::Linear); // 放大用线性过滤 m_texture->setWrapMode(QOpenGLTexture::ClampToEdge); // 边缘不重复平铺 initShaders(); // 编译着色器程序 initGeometry(); // 创建 VAO、VBO,设定顶点布局 }setMinificationFilter 和 setMagnificationFilter 都设成 Linear,是因为图像缩放后需要平滑过渡;如果设成 Nearest,缩得太小时会出现明显的锯齿闪动。ClampToEdge 让纹理坐标超出 0~1 时按边缘像素延伸,而不是重复平铺成瓷砖,对显示单张图片是正确的默认值。
上传图片时,先做格式转换,再提交像素数据:
void OpenGLImageWidget::setImage(const QImage &image) { // 统一转成 RGBA8888,保证内存布局与 GL 端 RGBA/UNSIGNED_BYTE 一致 m_image = image.convertToFormat(QImage::Format_RGBA8888); makeCurrent(); // 确保当前线程持有 GL 上下文 if (m_texture) { m_texture->setData(QOpenGLTexture::RGBA, QOpenGLTexture::UInt8, m_image.constBits()); } doneCurrent(); // 重置视图 m_scale = 1.0f; m_rotation = 0.0f; m_offset = QPointF(0, 0); update(); // 触发重绘 }setData 的参数分别是内部格式、像素数据类型、像素缓冲区指针。QImage::Format_RGBA8888 下,每个像素按 R、G、B、A 顺序排列,正好对应 QOpenGLTexture::RGBA 配 UInt8。有一个细节:QImage 的行可能存在额外对齐字节,如果行宽不是 4 的倍数,某些 Qt 版本会做内部补齐;保险起见也可以逐行上传,用 m_image.bytesPerLine() 控制每行长度,避免因对齐问题出现斜向错位。
几何初始化这里补一份,顶点位置使用原图像素坐标,两个三角形拼成一个矩形:
void OpenGLImageWidget::initGeometry() { // 矩形四个顶点,位置在前,纹理坐标在后 const GLfloat vertices[] = { 0.0f, 0.0f, 0.0f, 1.0f, m_image.width(), 0.0f, 1.0f, 1.0f, 0.0f, m_image.height(), 0.0f, 0.0f, m_image.width(), m_image.height(), 1.0f, 0.0f, }; m_vao.create(); m_vbo.create(); m_vao.bind(); m_vbo.bind(); m_vbo.allocate(vertices, sizeof(vertices)); m_program->enableAttributeArray(0); m_program->setAttributeBuffer(0, GL_FLOAT, 0, 2, 4 * sizeof(GLfloat)); m_program->enableAttributeArray(1); m_program->setAttributeBuffer(1, GL_FLOAT, 2 * sizeof(GLfloat), 2, 4 * sizeof(GLfloat)); m_vao.release(); }3.2 顶点与片元着色器:画面是怎样拼出来的
显示图片只需要三个部分:两个三角形组成的矩形、一张纹理、一个 uniform 矩阵。顶点着色器代码如下:
// simple.vert #version 330 core layout(location = 0) in vec2 aPosition; // 顶点坐标,单位是图像像素 layout(location = 1) in vec2 aTexCoord; // 纹理坐标,范围 0~1 uniform mat4 uMatrix; // 由 QMatrix4x4 传入 out vec2 vTexCoord; void main() { gl_Position = uMatrix * vec4(aPosition, 0.0, 1.0); vTexCoord = aTexCoord; }这里把顶点坐标直接设为原图像素坐标,比如 (0,0) 到 (width,height),矩阵的职责就变得统一:它负责把像素坐标变换到裁剪坐标。调试时看数值更直观,aPosition 是多少,纹理贴到哪个位置都可预期。uMatrix 由投影矩阵乘以模型变换矩阵得到,在 paintGL 里通过 setUniformValue 传入。
片元着色器更简单,只做一次纹理采样:
// simple.frag #version 330 core in vec2 vTexCoord; uniform sampler2D uTexture; out vec4 FragColor; void main() { FragColor = texture(uTexture, vTexCoord); }采样结果直接输出到颜色缓冲。如果你后面要做灰度、反色、浮雕这类滤镜,改这一处即可,比如把 FragColor 的 rgb 做加权平均再写回,显示控件本身的矩阵逻辑不用动。
3.3 变换矩阵:把缩放、旋转、平移统一作用在图像上
OpenGLImageWidget 对外暴露 rotateBy、resetView 这类高层接口,内部全部操作一个 QMatrix4x4。考虑到需求要求「缩放和旋转都围绕图像中心」,矩阵构造顺序需要注意:
QMatrix4x4 OpenGLImageWidget::buildTransform() const { QMatrix4x4 mat; // 把图像中心平移到原点 mat.translate(-m_imageCenter); // 先旋转再缩放,顺序影响最终形态 mat.rotate(m_rotation, 0.0f, 0.0f, 1.0f); // 绕 Z 轴旋转 mat.scale(m_scale); // 平移回图像中心,再叠加拖拽位移 mat.translate(m_imageCenter + m_offset); return mat; }为什么旋转和缩放要在中心附近做?因为图像如果围绕左上角旋转,视觉上就会到处乱飞,用户感觉不受控。这里先把图像中心搬到原点,完成变换后再搬回去,是典型的绕任意点旋转缩放写法。m_imageCenter 在 setImage 里取图像宽高的一半,这样旋转 90 度后图像依然留在视口中央附近。
投影矩阵使用正交投影,让窗口像素坐标直接对应图像坐标:
QMatrix4x4 OpenGLImageWidget::projectionMatrix() const { QMatrix4x4 proj; // 窗口左上角为原点,y 轴向下,和 QImage 坐标方向一致 proj.ortho(0.0f, width(), height(), 0.0f, -1.0f, 1.0f); return proj; }注意 ortho 参数把 top 设成 0、bottom 设成 height,这样 y 轴是向下的,正好让图像像素坐标从窗口左上角开始,坐标换算少一层翻转的麻烦。paintGL 里把两段矩阵乘起来送给着色器:
void OpenGLImageWidget::paintGL() { glClearColor(0.12f, 0.12f, 0.12f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); if (!m_texture || m_image.isNull()) return; QMatrix4x4 matrix = projectionMatrix() * buildTransform(); m_program->bind(); m_texture->bind(0); m_vao.bind(); m_program->setUniformValue("uMatrix", matrix); glDrawArrays(GL_TRIANGLES, 0, 6); }glClearColor 决定窗口背景色,图像查看器一般用深灰或纯黑,对比度好。矩阵顺序注意:取 projectionMatrix() * buildTransform(),顶点坐标先被右侧的模型变换处理,再进入投影坐标空间。如果顺序写反,图像会瞬间消失,这是很经典的 shader 矩阵坑。
3.4 鼠标事件换算:从窗口坐标到图像坐标的映射
滚轮缩放的核心需求,是「让光标所在的那个图像点保持不动」。先想把屏幕坐标反算成图像坐标,改完缩放后再把图像坐标映射回屏幕,把差值补进 offset:
void OpenGLImageWidget::wheelEvent(QWheelEvent *event) { // 获取光标在控件上的位置 QPointF screenPos = event->position(); // 反算当前变换下,这个点对应的图像坐标 QPointF imagePos = screenToImage(screenPos); // 缩放系数:滚轮一次变化 1.1 倍,向上放大向下缩小 qreal factor = event->angleDelta().y() > 0 ? 1.1 : 1.0 / 1.1; m_scale *= factor; if (m_scale < 0.01) m_scale = 0.01; if (m_scale > 100.0) m_scale = 100.0; // 用新矩阵反推图像坐标对应的新屏幕位置,差值就是平移补偿 QPointF newScreenPos = imageToScreen(imagePos); m_offset += screenPos - newScreenPos; update(); event->accept(); }screenToImage 与 imageToScreen 靠矩阵求逆完成一对正反变换:
QPointF OpenGLImageWidget::screenToImage(const QPointF &pos) const { QMatrix4x4 matrix = projectionMatrix() * buildTransform(); QMatrix4x4 inv = matrix.inverted(); QVector3D result = inv * QVector3D(pos.x(), pos.y(), 0.0f); return QPointF(result.x(), result.y()); } QPointF OpenGLImageWidget::imageToScreen(const QPointF &pos) const { QMatrix4x4 matrix = projectionMatrix() * buildTransform(); QVector3D result = matrix * QVector3D(pos.x(), pos.y(), 0.0f); return QPointF(result.x(), result.y()); }矩阵求逆在每帧做一次开销可以忽略,但如果图像很多、交互频繁,也可以在 buildTransform 返回时缓存逆矩阵,只在矩阵变化时刷新。拖拽平移更简单,直接维护 offset:
void OpenGLImageWidget::mousePressEvent(QMouseEvent *event) { m_lastMousePos = event->pos(); } void OpenGLImageWidget::mouseMoveEvent(QMouseEvent *event) { if (event->buttons() & Qt::LeftButton) { QPoint delta = event->pos() - m_lastMousePos; m_offset += delta; m_lastMousePos = event->pos(); update(); } }旋转用快捷键触发,每次旋转 90 度整数步,避免任意角度下文字方向变乱:
void OpenGLImageWidget::rotateBy(int degrees) { m_rotation = fmod(m_rotation + degrees, 360.0); if (m_rotation < 0) m_rotation += 360.0; update(); }到这里,显示、缩放、旋转、平移四个功能就完整闭环了,下面看怎么把它跑起来。
4. 编译运行与接入:把 demo 在 Qt 6 环境里跑起来
4.1 CMake 工程配置与 Qt 模块依赖
工程建议用 Qt 6 + CMake,配置如下:
cmake_minimum_required(VERSION 3.16) project(ImageGLViewer LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 COMPONENTS Widgets OpenGLWidgets OpenGL REQUIRED) qt_add_executable(ImageGLViewer main.cpp mainwindow.cpp mainwindow.h opengl_image_widget.cpp opengl_image_widget.h ) target_link_libraries(ImageGLViewer PRIVATE Qt6::Widgets Qt6::OpenGLWidgets Qt6::OpenGL )Qt 6 里 QOpenGLWidget 归在 OpenGLWidgets 模块,OpenGL 工具类在 Qt6::OpenGL。如果是 Qt 5,find_package 里的 OpenGLWidgets 要去掉,QOpenGLWidget 属于 Qt5::Widgets,图形功能在 Qt5::Gui 里,链接路径略有差异但不大。老项目用 qmake 的话,在 .pro 里写 QT += widgets opengl 也能编译通过。
4.2 主窗口装配:加载图片、初始化 GL 格式
main.cpp 里先设置 OpenGL 版本。Qt 6 默认请求的上下文版本可能偏低,建议显式指定 3.3 Core,与着色器里的 #version 330 对应:
int main(int argc, char *argv[]) { QApplication app(argc, argv); // 指定 OpenGL 3.3 Core Profile,和 shader 版本匹配 QSurfaceFormat format; format.setRenderableType(QSurfaceFormat::OpenGL); format.setVersion(3, 3); format.setProfile(QSurfaceFormat::CoreProfile); format.setDepthBufferSize(24); QSurfaceFormat::setDefaultFormat(format); MainWindow window; window.resize(1024, 768); window.show(); return app.exec(); }注意 setDefaultFormat 必须在创建 QOpenGLWidget 之前调用,否则格式不会生效。depth buffer 这个项目本用不到,留 24 位是为了兼容后面可能加裁剪、拾取功能的场景。
MainWindow 里把打开图片和 OpenGLImageWidget 接起来:
void MainWindow::openImage() { QString filePath = QFileDialog::getOpenFileName(this, tr("Open Image"), "", tr("Images (*.png *.jpg *.jpeg *.bmp *.tif)")); if (filePath.isEmpty()) return; QImage image(filePath); if (image.isNull()) { QMessageBox::warning(this, tr("Error"), tr("Failed to load image.")); return; } m_glWidget->setImage(image); setWindowTitle(tr("Image Viewer - %1").arg(QFileInfo(filePath).fileName())); }QImage 能读常见图片格式。需要注意,Qt 默认不带 TIFF 解码插件,如果打开 TIFF 失败,去 Qt 安装目录确认 imageformats 插件齐全即可。加载照片类图片时,建议用 QImageReader 设置 setAutoTransform(true),自动处理 EXIF 方向,免得手机上拍的竖图传到电脑后横躺着。
4.3 运行验证与调试手段
构建并运行:
cmake -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j8 ./build/ImageGLViewer跑起来后做三件事验证:第一,打开一张纹理细节丰富的图片,滚动滚轮确认放大缩小平滑;第二,拖拽平移,确认图像边缘可以拖出窗口再拖回来,不会飞丢;第三,按 R 键旋转 90 度,确认图像居中且方向正确。
如果画面一片黑,第一件事是查着色器编译日志,在 initShaders 里一般会做:
bool OpenGLImageWidget::initShaders() { if (!m_program->addShaderFromSourceFile(QOpenGLShader::Vertex, ":/shaders/simple.vert")) { qDebug() << m_program->log(); return false; } if (!m_program->addShaderFromSourceFile(QOpenGLShader::Fragment, ":/shaders/simple.frag")) { qDebug() << m_program->log(); return false; } if (!m_program->link()) { qDebug() << m_program->log(); return false; } return true; }在 macOS 上如果图像上下颠倒,大概率是纹理坐标的 V 方向和图像数据不一致,下一章会专门讲。在 Windows 上要确认显卡驱动支持 OpenGL 3.3,旧驱动会把上下文创建失败直接表现为黑屏。Linux 桌面如果落在 Mesa 软件渲染上,速度会明显变慢,换 GPU 驱动就好,这类环境问题跟项目本身关系不大,先记一笔。
5. 避坑记录:纹理坐标、DPI 与 GL 上下文的前车之鉴
5.1 图像上下颠倒
现象:图片第一行像素被渲染到窗口底部,整张图上下翻转。 原因:OpenGL 纹理坐标系原点在左下角,而 QImage 的第一行数据对应图像顶部。直接把 QImage 像素扔给纹理,再按原样的纹理坐标采样,图像就会倒过来。 解决:在顶点数据里把纹理坐标的 y 翻转一次。注意是改 texcoord,不是改顶点位置:
// 纹理坐标 y 方向翻转,抵消 QImage 与 GL 纹理的原点差异 const GLfloat texCoords[] = { 0.0f, 1.0f, // 左下角 1.0f, 1.0f, // 右下角 0.0f, 0.0f, // 左上角 1.0f, 0.0f, // 右上角 };这样顶点坐标仍然从左下到右上,但纹理内容的 V 方向被翻转,画面就和原图方向一致了。如果代码里已经用 QOpenGLTexture 的 setVerticalFlip 之类的接口,也可以直接交给纹理对象处理,效果一样。
5.2 第一次上传纹理却黑屏
现象:程序启动后打开图片,界面只有背景色,没有任何图像,日志也没报错。 原因:QOpenGLTexture 虽然是在 initializeGL 里创建的,但 setImage 是从外部调用的,调用时没有执行 makeCurrent,纹理数据根本没进入显存。 解决:setImage 里用 makeCurrent 和 doneCurrent 包住所有纹理修改:
void OpenGLImageWidget::setImage(const QImage &image) { makeCurrent(); m_image = image.convertToFormat(QImage::Format_RGBA8888); if (m_texture) { m_texture->setData(QOpenGLTexture::RGBA, QOpenGLTexture::UInt8, m_image.constBits()); } doneCurrent(); update(); }这是一个典型的 GL 上下文使用问题:上下文有「当前线程」概念,任何纹理、着色器操作都要求当前线程已经是上下文线程。在 Qt 里就是 makeCurrent/doneCurrent 这组调用要配对出现。
5.3 窗口拉伸后图片变形
现象:把窗口拖宽,图像被水平拉长,人物或圆形都变成扁的。 原因:投影矩阵按窗口宽高生成,但 glViewport 和窗口尺寸没有同步更新,或者投影矩阵用的是初始化时的旧宽高。 解决:在 resizeGL 里重新计算投影,并强制重绘:
void OpenGLImageWidget::resizeGL(int w, int h) { glViewport(0, 0, w, h); update(); }QOpenGLWidget 实际上会在 resize 时自动调用 glViewport,但投影矩阵要自己更新。只要 projectionMatrix() 每次取当前 width() 和 height(),窗口拉伸就不会变形。关键点是不能把投影矩阵缓存起来,必须每帧现算或监听 resize。
5.4 高 DPI 下鼠标坐标错位
现象:Windows 系统缩放设为 125% 或 150% 时,滚轮放大的中心点明显不在光标位置,缩放总往左上角偏。 原因:Qt 启用高 DPI 缩放后,QWheelEvent 的 position 返回的是逻辑像素坐标,而 OpenGL 的视口和宽高可能基于物理像素,两边比例不一致。 解决:在事件入口先除一次 devicePixelRatioF,把逻辑坐标换算成控件本地物理坐标,再做后续矩阵换算:
qreal ratio = devicePixelRatioF(); QPointF pos = event->position() / ratio;这个坑在 2K、4K 屏上特别常见,症状和矩阵写错很像,但本质是坐标系的单位不同。判断方法是把窗口在不同缩放比例下分别测试,如果错位的程度随系统缩放变化,基本就是 DPI 问题。
5.5 异步加载图片导致 GL 崩溃
现象:用后台线程解码大图,完成后把 QImage 交回主线程,程序偶发崩溃,崩溃栈指向纹理上传或 glDeleteTextures。 原因:OpenGL 对象只能在创建它的线程里操作。后台线程虽然没有直接碰纹理,但如果它把 QImage 数据指针传给主线程,主线程再做 setData,可能和当前绘制线程产生竞争,崩溃就成了玄学。 解决:后台线程只负责解码,完成后通过信号切回主线程再调用 setImage。这是 Qt 的标准跨线程写法:
// 后台线程完成时发出信号 emit imageReady(image); // MainWindow 中连接 connect(worker, &ImageLoadWorker::imageReady, this, [this](const QImage &img) { m_glWidget->setImage(img); });从那以后我每次写 QOpenGLWidget 的图片加载,都会强制走一遍「后台解码 + 主线程上传」的流程。麻烦是麻烦点,但至少不会莫名其妙黑屏。
6. 二次开发:从图片查看器进阶成标注工具的三个改造
6.1 在画面上叠加网格与标注线
如果要做切片浏览或图像比对工具,第一件事通常是叠加参考网格。网格不一定要新增几何体,在片元着色器里判断像素位置也能实现:
float grid = step(0.95, fract(vTexCoord * uGridCount)); FragColor = mix(texture(uTexture, vTexCoord), vec4(1.0), grid * 0.5);uGridCount 是 uniform 网格密度。这样做的缺点是网格会跟随纹理坐标变化,缩放时格距会变,比较适合临时参考线。要固定物理格距,更稳的做法是用顶点着色器里的坐标乘上当前 scale,再对某个固定像素间隔取模,这里不展开,先把思路留给你。
6.2 把图像像素坐标实时换算给状态栏
标注工具需要知道「鼠标此时对应原图的哪个像素」。上一章的 screenToImage 正好派上用场,在 mouseMoveEvent 里取到图像坐标后发信号给状态栏:
void OpenGLImageWidget::updateStatusBar(const QPointF &imagePos) { int x = qBound(0, int(imagePos.x()), m_image.width() - 1); int y = qBound(0, int(imagePos.y()), m_image.height() - 1); emit statusMessage(QString("Pixel: (%1, %2)").arg(x).arg(y)); }注意旋转之后,图像坐标不再等价于原始像素行列,必须用逆矩阵换算。旋转 90 度会让宽高互换,qBound 的边界建议取变换后图像四角的坐标,而不是直接用 m_image 的宽高。
6.3 给操作补一组快捷键
给 MainWindow 加上 QShortcut,把常用操作全部暴露成键盘入口。功能面板上可以放一张快捷键表:
| 操作 | 快捷键 |
|---|---|
| 打开图片 | Ctrl+O |
| 放大 | Ctrl+= |
| 缩小 | Ctrl+- |
| 旋转 90 度 | R |
| 重置视图 | 0 |
实现时注意复用 OpenGLImageWidget 已有的 setImage、rotateBy 方法,MainWindow 只做键位绑定,不直接碰矩阵。重置视图调用 resetView(),把 scale、rotation、offset 一次清干净。
我做这类工具的习惯是:rotateBy 内部统一接收整数度数,缩放限死在最小 0.01、最大 100。这两个约束看起来像是玄学,但确实能避免用户把图像拖到找不回来。每次改动矩阵,顺手在状态栏打印一下 scale 和 rotation,调试效率和体验都会提升不少。希望帮到你。
本文还有配套的精品资源,点击获取