縮放)
簡(jiǎn)介這份Qt示例工程面向具備一定C基礎(chǔ)、正在學(xué)習(xí)Qt與OpenGL圖形顯示的開(kāi)發(fā)者聚焦QImage圖片在QOpenGLWidget中的紋理上傳與交互變換問(wèn)題。壓縮包為rar格式共10個(gè)文件、約347KB以3個(gè)cpp和2個(gè)h源碼為核心配合ui界面、qrc資源、pro工程配置及jpg示例圖組成可直接參考的完整工程結(jié)構(gòu)。已有783人學(xué)習(xí)說(shuō)明其在圖像顯示類練習(xí)中具有一定參考價(jià)值。工程內(nèi)容覆蓋initializeGL與paintGL的初始化與繪制流程、紋理過(guò)濾和邊角模式設(shè)置以及通過(guò)模型視圖矩陣實(shí)現(xiàn)縮放、旋轉(zhuǎn)和平移的思路同時(shí)結(jié)合鼠標(biāo)、鍵盤輸入更新變換參數(shù)并控制重繪以保持交互流暢。讀者可據(jù)此理解QImage轉(zhuǎn)紋理、視口適配和交互變換的組織方式作為圖像查看器或圖形界面二次開(kāi)發(fā)的起點(diǎn)。1. QOpenGLWidget 貼圖這件事從 QLabel 換到 GL 畫布的那一步拿 QLabel 顯示圖片一開(kāi)始都很順直到你放進(jìn)去一張 8000×6000 的掃描件。滾輪一放大QPixmap 要重新做一次 CPU 插值幾十毫秒就過(guò)去了按住鼠標(biāo)拖動(dòng)主線程直接被堵住畫面一卡一頓松手才啪地跳到最終位置。做圖像標(biāo)注、圖紙預(yù)覽、看圖工具這類要長(zhǎng)時(shí)間盯細(xì)節(jié)的軟件這個(gè)體驗(yàn)基本沒(méi)法交付。換成 QOpenGLWidget 的思路其實(shí)很樸素讓 QImage 只走一次內(nèi)存到顯存的拷貝變成一張紋理之后所有縮放、旋轉(zhuǎn)、平移都只是改一個(gè) 4×4 矩陣?yán)锏膸讉€(gè)數(shù)重繪交給 GPU大圖縮放穩(wěn)定在一幀一次 draw call。這份源碼素材-template 拆的就是這個(gè)最小閉環(huán)四類操作背后只有一條數(shù)據(jù)鏈路和一組矩陣把這條鏈走通再加任何功能都是往上疊。適合剛接觸 Qt 與 OpenGL 的同學(xué)照著跑通也適合手上正有一個(gè) QLabel 看圖控件準(zhǔn)備換掉的開(kāi)發(fā)者。2. 工程骨架與數(shù)據(jù)鏈路一張 QImage 走到屏幕要過(guò)哪幾道關(guān)2.1 四個(gè)環(huán)節(jié)解碼、上傳、變換、繪制整條鏈路拆開(kāi)只有四步邊界清楚到可以用一句話描述QImage 在 CPU 側(cè)被規(guī)范化成 RGBA8888 并垂直翻轉(zhuǎn)通過(guò) glTexImage2D 上傳成 2D 紋理繪制時(shí)用一個(gè)正交投影矩陣把像素坐標(biāo)系映射到 NDC再把平移、旋轉(zhuǎn)、縮放疊成一個(gè)模型矩陣最后畫一個(gè)和圖片等尺寸的四邊形采樣紋理輸出。四個(gè)環(huán)節(jié)各自有獨(dú)立的失敗點(diǎn)這是排查時(shí)最省時(shí)間的一張地圖環(huán)節(jié)關(guān)鍵函數(shù)典型失敗表現(xiàn)解碼QImage::load / convertToFormat通道錯(cuò)位、透明區(qū)變黑上傳glGenTextures / glTexImage2D黑屏、斜條紋、越界讀變換QMatrix4x4 組裝縮放跑偏、旋轉(zhuǎn)不繞中心繪制glDrawArrays shader有頂點(diǎn)無(wú)像素、上下顛倒很多人第一次寫會(huì)把這四步揉在 paintGL 里結(jié)果一改縮放就得重新傳紋理幀率立刻掉到個(gè)位數(shù)。正確的切法是上傳只在換圖時(shí)發(fā)生一次paintGL 里只做變換和采樣。2.2 類結(jié)構(gòu)與目錄組織工程不需要很復(fù)雜一個(gè)畫布類吃下所有 GL 相關(guān)代碼主窗口只管 UI 和文件對(duì)話框這樣二次開(kāi)發(fā)時(shí)改動(dòng)面可控。目錄大致長(zhǎng)這樣ImageViewerGL/ ├── CMakeLists.txt ├── main.cpp ├── widgets/ │ ├── ImageCanvasWidget.h # 畫布本體GL 代碼全在這里 │ ├── ImageCanvasWidget.cpp │ ├── ImageViewerWindow.h # 主窗口打開(kāi)、復(fù)位、關(guān)于 │ └── ImageViewerWindow.cpp ├── shaders/ │ ├── image.vert │ └── image.frag └── resources.qrc # 把 shader 打成資源省掉運(yùn)行時(shí)路徑問(wèn)題Qt6 里 QOpenGLWidget 已經(jīng)從 Widgets 拆到了獨(dú)立模塊CMake 配置少寫一行就是鏈接報(bào)錯(cuò)find_package(Qt6 REQUIRED COMPONENTS Widgets OpenGLWidgets) add_executable(ImageViewerGL main.cpp widgets/ImageCanvasWidget.cpp widgets/ImageViewerWindow.cpp ) # 注意 OpenGLWidgets 是獨(dú)立組件只鏈 Widgets 會(huì)在 QOpenGLWidget 構(gòu)造函數(shù)上炸 target_link_libraries(ImageViewerGL PRIVATE Qt6::Widgets Qt6::OpenGLWidgets) set_target_properties(ImageViewerGL PROPERTIES WIN32_EXECUTABLE TRUE MACOSX_BUNDLE TRUE )提示Qt5 用QT opengl就夠了Qt6 必須顯式找 OpenGLWidgets兩套 CMake 寫法不通用從舊工程遷移時(shí)優(yōu)先查這一行。畫布類的公開(kāi)接口刻意收得很窄外層只認(rèn)識(shí)三個(gè)方法加載圖片、復(fù)位視圖、設(shè)置背景色。內(nèi)部狀態(tài)全部私有這樣后面加標(biāo)注層、加 ROI 框選時(shí)外部邏輯不需要知道矩陣怎么算。2.3 初始化順序initializeGL 里到底該放什么QOpenGLWidget 的三個(gè)虛函數(shù)有嚴(yán)格調(diào)用時(shí)機(jī)initializeGL只在上下文第一次成功建立后調(diào)一次resizeGL在尺寸變化時(shí)調(diào)paintGL每幀調(diào)。順序錯(cuò)了就是典型黑屏——在initializeGL之前調(diào)任何gl開(kāi)頭的函數(shù)要么空指針要么靜默失敗。class ImageCanvasWidget : public QOpenGLWidget, protected QOpenGLFunctions { Q_OBJECT public: explicit ImageCanvasWidget(QWidget *parent nullptr); ~ImageCanvasWidget() override; bool loadImage(const QString filePath); // 唯一換圖入口 void resetView(); // 復(fù)位縮放 1.0、旋轉(zhuǎn) 0、居中 void rotateBy(float degrees); // 相對(duì)旋轉(zhuǎn)供工具欄和快捷鍵調(diào) protected: void initializeGL() override; void resizeGL(int w, int h) override; void paintGL() override; void wheelEvent(QWheelEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; private: void uploadTexture(const QImage img); // QImage - GL 紋理只在換圖時(shí)調(diào) void rebuildQuad(); // 按圖片尺寸重建頂點(diǎn)緩沖 QMatrix4x4 buildMvp() const; // 投影 * 模型逐幀組裝 GLuint m_texture 0; QOpenGLShaderProgram m_program; QOpenGLBuffer m_vbo{QOpenGLBuffer::VertexBuffer}; QOpenGLVertexArrayObject m_vao; QImage m_image; // 保留原圖導(dǎo)出/重傳都靠它 QSize m_texSize; // 原圖尺寸算初始縮放用 float m_scale 1.0f; // 縮放系數(shù)1.0 表示 1 像素對(duì) 1 像素 float m_rotation 0.0f; // 旋轉(zhuǎn)角度單位度逆時(shí)針為正 QVector2D m_translation{0.0f, 0.0f}; // 平移量單位屏幕像素 QPointF m_lastPos; // 上一幀鼠標(biāo)位置拖拽用 bool m_dragging false; };繼承QOpenGLFunctions是為了拿到跨平臺(tái)的 GL 函數(shù)指針集合——Qt 幫你做了QOpenGLContext::getProcAddress那一套你不必再引 GLEW 或 glad這在只有 Widgets 依賴的項(xiàng)目里很省事。代價(jià)是只能用 OpenGL ES 2.0 / 2.1 這個(gè)基線如果你要用 compute shader 或幾何著色器就得換成QOpenGLExtraFunctions或者自己加載。initializeGL里的順序不能亂void ImageCanvasWidget::initializeGL() { // 1. Qt6 必須顯式初始化函數(shù)解析表少了這行后面所有 gl* 都是空指針 initializeOpenGLFunctions(); // 2. shader 編譯鏈接失敗必須立刻暴露不要靜默跳過(guò) if (!m_program.addShaderFromSourceCode(QOpenGLShader::Vertex, kVertexShader) || !m_program.addShaderFromSourceCode(QOpenGLShader::Fragment, kFragmentShader) || !m_program.link()) { qWarning(shader 編譯失敗: %s, qPrintable(m_program.log())); return; } // 3. VAO/VBO 只創(chuàng)建不填充頂點(diǎn)數(shù)據(jù)要等圖片尺寸確定后才能寫 m_vao.create(); m_vbo.create(); // 4. 固定管線那套混色狀態(tài)深色背景配半透明圖片時(shí)需要 glDisable(GL_DEPTH_TEST); glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); }第 4 步的GL_BLEND是給帶 alpha 通道的 PNG 準(zhǔn)備的如果你只顯示不透明圖片這一行可以省。第 2 步的qWarning打印m_program.log()很關(guān)鍵——shader 編譯錯(cuò)誤不會(huì)拋異常只會(huì)在日志里留一行不打印出來(lái)就是純黑屏加一頭霧水。3. 核心渲染鏈路拆解紋理上傳、著色器與 MVP 矩陣3.1 QImage 轉(zhuǎn)紋理格式、對(duì)齊、翻轉(zhuǎn)三件事這是整個(gè)項(xiàng)目里最容易翻車的一段因?yàn)槿齻€(gè)坑全藏在兩行代碼里。QImage 的像素格式五花八門Format_ARGB32、Format_RGB888、Format_Grayscale8……OpenGL 只認(rèn)它自己那一套枚舉中間必須做一次格式收斂。選Format_RGBA8888是因?yàn)樗膬?nèi)存排布是 R、G、B、A 順序和GL_RGBA的期望完全一致不需要再做通道重排。void ImageCanvasWidget::uploadTexture(const QImage img) { // 1. 格式收斂RGBA8888 的內(nèi)存序和 GL_RGBA 嚴(yán)格對(duì)應(yīng)省掉通道交換 QImage normalized img.convertToFormat(QImage::Format_RGBA8888); // 2. 垂直翻轉(zhuǎn)QImage 原點(diǎn)在左上GL 紋理原點(diǎn)在左下這里翻一次 // 著色器里的 uv 就可以老老實(shí)實(shí)寫 (0,0)-(1,1)邏輯只留在一個(gè)地方 normalized normalized.mirrored(false, true); if (m_texture 0) { glGenTextures(1, m_texture); } glBindTexture(GL_TEXTURE_2D, m_texture); // 3. 行對(duì)齊寬度不是 4 的倍數(shù)時(shí)默認(rèn) 4 字節(jié)對(duì)齊會(huì)讀串行畫面呈斜條紋 glPixelStorei(GL_UNPACK_ALIGNMENT, 1); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, normalized.width(), normalized.height(), 0, GL_RGBA, GL_UNSIGNED_BYTE, normalized.constBits()); // 4. 縮小時(shí)用 mipmap 三線性放大時(shí)用線性只用 GL_LINEAR 縮小會(huì)明顯閃爍 glGenerateMipmap(GL_TEXTURE_2D); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // 5. 邊緣 clamp避免紋理采樣越界時(shí)出現(xiàn)半透明黑邊 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glBindTexture(GL_TEXTURE_2D, 0); m_texSize normalized.size(); m_image img; // 存原圖導(dǎo)出時(shí)不要再?gòu)?GPU 讀回來(lái) }參數(shù)逐個(gè)說(shuō)清楚GL_RGBA8是內(nèi)部格式8 位每通道夠用且顯存占用可控如果做醫(yī)療影像要 16 位精度這里換成GL_RGBA16同時(shí) QImage 那邊轉(zhuǎn)Format_RGBA64。glPixelStorei(GL_UNPACK_ALIGNMENT, 1)是必須的。寬度 3 的倍數(shù)、又不是 4 的倍數(shù)的圖比如 RGB888 的 1023 像素寬默認(rèn) 4 字節(jié)對(duì)齊會(huì)讓驅(qū)動(dòng)多讀幾個(gè)字節(jié)畫面直接變成斜著的色帶。這個(gè)現(xiàn)象非常迷惑因?yàn)閳D不大就完全正常圖一大就錯(cuò)。mirrored(false, true)和第二參數(shù)true是垂直鏡像。如果你不想在 CPU 側(cè)翻也可以在頂點(diǎn)數(shù)據(jù)里把 v 坐標(biāo)寫成1.0 - v但兩邊千萬(wàn)別都做否則畫面上下顛倒回來(lái)又顛倒回去最后看起來(lái)正常一換圖就露餡。紋理尺寸還有硬限制GL_MAX_TEXTURE_SIZE在老舊集顯上可能只有 8192 或 4096。超過(guò)這個(gè)尺寸glTexImage2D直接報(bào)GL_INVALID_VALUE畫面全黑。工業(yè)相機(jī)、衛(wèi)星圖、切片掃描這類動(dòng)輒兩萬(wàn)像素的材料很正常比較穩(wěn)妥的做法是分塊tile上傳或者先等比縮到合法尺寸再傳原圖留給導(dǎo)出用。3.2 頂點(diǎn)數(shù)據(jù)與著色器一張四邊形的完整定義幾何部分簡(jiǎn)單到不需要索引緩沖四個(gè)頂點(diǎn)畫一個(gè) triangle strip 就完事。頂點(diǎn)坐標(biāo)直接用圖片像素坐標(biāo)0,0 到 w,h這樣正交投影矩陣寫起來(lái)最直觀鼠標(biāo)坐標(biāo)也能直接參與運(yùn)算不用來(lái)回做歸一化換算。void ImageCanvasWidget::rebuildQuad() { const float w static_castfloat(m_texSize.width()); const float h static_castfloat(m_texSize.height()); // 布局x, y, u, v 交叉排列一次 allocate 搞定位置和紋理坐標(biāo) const GLfloat verts[] { 0.0f, 0.0f, 0.0f, 0.0f, // 左上 w, 0.0f, 1.0f, 0.0f, // 右上 0.0f, h, 0.0f, 1.0f, // 左下 w, h, 1.0f, 1.0f, // 右下 }; m_vao.bind(); m_vbo.bind(); m_vbo.allocate(verts, sizeof(verts)); m_program.bind(); // 參數(shù)含義location0類型 float起始偏移 0每頂點(diǎn) 2 分量跨距 4 個(gè) float m_program.enableAttributeArray(0); m_program.setAttributeBuffer(0, GL_FLOAT, 0, 2, 4 * sizeof(GLfloat)); // location1從第 3 個(gè) float 開(kāi)始2 分量跨距同上 m_program.enableAttributeArray(1); m_program.setAttributeBuffer(1, GL_FLOAT, 2 * sizeof(GLfloat), 2, 4 * sizeof(GLfloat)); m_program.release(); m_vbo.release(); m_vao.release(); }setAttributeBuffer的最后一個(gè)參數(shù)是 stride這里寫4 * sizeof(GLfloat)表示每隔 16 字節(jié)取下一個(gè)頂點(diǎn)。如果寫成 0讓驅(qū)動(dòng)自己推某些老驅(qū)動(dòng)會(huì)算錯(cuò)顯式寫死更保險(xiǎn)。location0 / 1是靠 shader 里的layout(location ...)硬綁定的兩邊必須對(duì)得上對(duì)不上就是純黑。兩個(gè) shader 保持最小// image.vert #version 330 core layout(location 0) in vec2 aPos; layout(location 1) in vec2 aTexCoord; uniform mat4 uMvp; out vec2 vTexCoord; void main() { vTexCoord aTexCoord; // 注意這里乘的是完整 MVP任何變換都在 CPU 側(cè)算好shader 不做二次加工 gl_Position uMvp * vec4(aPos, 0.0, 1.0); }// image.frag #version 330 core in vec2 vTexCoord; uniform sampler2D uTexture; out vec4 fragColor; void main() { fragColor texture(uTexture, vTexCoord); }片元著色器里不寫任何 if 和分支——GPU 最怕這個(gè)。如果你想加灰度化反色亮度對(duì)比度直接在texture()之后做一個(gè)mix或加減代價(jià)幾乎為零但如果寫if (vTexCoord.x 0.5)性能曲線會(huì)立刻變得難看。3.3 buildMvp平移、旋轉(zhuǎn)、縮放的合成順序矩陣這塊是玄學(xué)重災(zāi)區(qū)。QMatrix4x4 的乘法和數(shù)學(xué)課本一致但代碼里鏈?zhǔn)秸{(diào)用的順序和直覺(jué)是反的m.translate(); m.rotate(); m.scale();實(shí)際效果是先縮放、再旋轉(zhuǎn)、最后平移因?yàn)槊看握{(diào)用都在右乘新矩陣。設(shè)計(jì)意圖很明確圖片自己的坐標(biāo)系里先繞圖片中心縮放再繞圖片中心旋轉(zhuǎn)然后整體移動(dòng)到畫布中心并疊加用戶的平移量。所以順序必須是 translate → rotate → scale → 反向平移回原點(diǎn)。QMatrix4x4 ImageCanvasWidget::buildMvp() const { QMatrix4x4 proj; // y 軸向下bottom h, top 0和屏幕坐標(biāo)系一致 // 鼠標(biāo)坐標(biāo)可以直接當(dāng)世界坐標(biāo)用不需要再做 y 翻轉(zhuǎn) proj.ortho(0.0f, static_castfloat(width()), static_castfloat(height()), 0.0f, -1.0f, 1.0f); QMatrix4x4 mv; // 1. 把畫布中心 用戶平移量當(dāng)作圖片中心的落點(diǎn) mv.translate(width() * 0.5f m_translation.x(), height() * 0.5f m_translation.y()); // 2. 繞 Z 軸旋轉(zhuǎn)正數(shù)為逆時(shí)針 mv.rotate(m_rotation, 0.0f, 0.0f, 1.0f); // 3. 等比縮放 mv.scale(m_scale, m_scale); // 4. 把圖片自身中心挪到原點(diǎn) —— 少了這行縮放和旋轉(zhuǎn)會(huì)繞畫布左上角 // 表現(xiàn)就是放大時(shí)圖片往右下角飛出屏幕 mv.translate(-m_texSize.width() * 0.5f, -m_texSize.height() * 0.5f); return proj * mv; }第 4 行的反向平移是整個(gè)矩陣的靈魂。它的物理含義是把圖片坐標(biāo)系的中心挪到原點(diǎn)這樣第 2、3 步的旋轉(zhuǎn)和縮放天然繞中心發(fā)生。很多人圖省事把 scale 寫在 translate 前面代碼看起來(lái)只差一行運(yùn)行起來(lái)圖片就是繞著左上角放大越放越偏還以為是自己縮放算法寫錯(cuò)了。proj * mv的乘法順序也不能改投影矩陣在最左模型矩陣在右。寫成mv * proj的后果是畫面上什么都沒(méi)有且沒(méi)有任何報(bào)錯(cuò)。3.4 paintGL一幀的完整執(zhí)行順序void ImageCanvasWidget::paintGL() { glClearColor(0.13f, 0.14f, 0.16f, 1.0f); // 深灰底淺色圖邊緣更清楚 glClear(GL_COLOR_BUFFER_BIT); if (m_texture 0 || m_texSize.isEmpty()) { return; // 還沒(méi)加載圖片只清屏 } m_program.bind(); m_program.setUniformValue(uMvp, buildMvp()); m_program.setUniformValue(uTexture, 0); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, m_texture); m_vao.bind(); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); m_vao.release(); m_program.release(); }逐行解釋關(guān)鍵點(diǎn)setUniformValue(uTexture, 0)里的 0 是紋理單元編號(hào)必須和glActiveTexture(GL_TEXTURE0)對(duì)上對(duì)不上就是采樣到一張空紋理畫面純黑。GL_TRIANGLE_STRIP用 4 個(gè)頂點(diǎn)畫兩個(gè)三角形比GL_TRIANGLES少傳兩個(gè)頂點(diǎn)雖然在這里省不了多少但習(xí)慣值得保留。整個(gè)paintGL里沒(méi)有任何glTexImage2D這是幀率能穩(wěn)住的前提。換圖時(shí)才調(diào)uploadTexturerebuildQuad然后update()一次。4. 交互層實(shí)現(xiàn)滾輪縮放、拖拽平移與旋轉(zhuǎn)的坐標(biāo)換算4.1 鼠標(biāo)事件分發(fā)與狀態(tài)管理三個(gè)鼠標(biāo)事件各管一攤互不打擾左鍵按下開(kāi)始拖拽左鍵移動(dòng)累加平移量滾輪改縮放系數(shù)右鍵拖拽改旋轉(zhuǎn)角。狀態(tài)用一個(gè)m_dragging布爾量 一個(gè)m_lastPos就能覆蓋不需要維護(hù)狀態(tài)機(jī)。void ImageCanvasWidget::mousePressEvent(QMouseEvent *e) { if (e-button() Qt::LeftButton) { m_dragging true; m_lastPos e-position(); // Qt6 用 position()Qt5 是 pos() setCursor(Qt::ClosedHandCursor); } QOpenGLWidget::mousePressEvent(e); } void ImageCanvasWidget::mouseMoveEvent(QMouseEvent *e) { if (!m_dragging || !(e-buttons() Qt::LeftButton)) { return; } const QPointF delta e-position() - m_lastPos; m_lastPos e-position(); // 平移量直接在屏幕像素空間累加和 ortho 的像素單位完全一致 // 中間不需要任何比例換算這也是選像素坐標(biāo)系的最大好處 m_translation QVector2D(static_castfloat(delta.x()), static_castfloat(delta.y())); update(); // 單幀請(qǐng)求重繪不要用 while 或 timer 去刷 } void ImageCanvasWidget::mouseReleaseEvent(QMouseEvent *e) { if (e-button() Qt::LeftButton) { m_dragging false; unsetCursor(); } QOpenGLWidget::mouseReleaseEvent(e); }e-position()返回的是邏輯像素DPI 無(wú)關(guān)而m_translation也是邏輯像素兩邊單位一致。如果你不小心一邊用了devicePixelRatio乘過(guò)的物理像素拖拽速度就會(huì)變成 2 倍或 0.5 倍高 DPI 屏上線普通屏上一切正?!@種問(wèn)題最難查因?yàn)殚_(kāi)發(fā)機(jī)往往是普通屏。4.2 以光標(biāo)為錨點(diǎn)的縮放最樸素的實(shí)現(xiàn)是滾輪一下m_scale * 1.15圍繞畫布中心放大。用起來(lái)會(huì)讓用戶很煩想看清楚畫面右上角的細(xì)節(jié)滾幾圈之后那塊內(nèi)容已經(jīng)跑到屏幕外面去了得不停拖回來(lái)。好用的看圖工具都做光標(biāo)錨定——放大時(shí)鼠標(biāo)指針下面那個(gè)像素點(diǎn)保持不動(dòng)。推導(dǎo)不復(fù)雜把屏幕坐標(biāo)到世界坐標(biāo)的映射寫成一行screen R · S · (world ? c) center t其中 R 是旋轉(zhuǎn)S 是縮放c 是圖片中心t 是平移量。要求縮放從 S 變到 S 之后同一個(gè) screen 對(duì)應(yīng)的 world 不變解出新的平移量t pivot · (1 ? k) t · k, 其中 k S/Spivot 鼠標(biāo)位置 ? 畫布中心落到代碼里只有六行void ImageCanvasWidget::wheelEvent(QWheelEvent *e) { const float steps e-angleDelta().y() / 120.0f; // 一格滾輪 120 if (qFuzzyIsNull(steps)) { return; } const float oldScale m_scale; // 1.15^steps往上滾放大往下滾縮小指數(shù)形式保證放大縮小完全對(duì)稱 m_scale std::clamp(oldScale * std::pow(1.15f, steps), 0.02f, 64.0f); // 錨定公式k 是新舊縮放比pivot 是鼠標(biāo)相對(duì)畫布中心的偏移 const float k m_scale / oldScale; const QPointF pos e-position(); const QVector2D pivot(static_castfloat(pos.x() - width() * 0.5), static_castfloat(pos.y() - height() * 0.5)); m_translation pivot * (1.0f - k) m_translation * k; update(); e-accept(); }幾個(gè)參數(shù)值得說(shuō)明std::pow(1.15f, steps)里的 1.15 是手感系數(shù)試過(guò) 1.1、1.2、1.31.15 在大圖上大概是滾三格看清楚一小塊的節(jié)奏再大就容易失控。std::clamp的上下限 0.02 和 64 是防呆——低于 0.02 時(shí)一張 10000 像素的圖只剩 200 像素寬用戶會(huì)以為圖沒(méi)了高于 64 時(shí)浮點(diǎn)精度開(kāi)始不夠用邊緣會(huì)出現(xiàn)抖動(dòng)。這兩個(gè)邊界值不是隨便定的是長(zhǎng)時(shí)間試出來(lái)的。還有一點(diǎn)容易忽略公式里的pivot用的是畫布中心而不是圖片中心。這里有個(gè)前提buildMvp里平移量的基準(zhǔn)點(diǎn)也是畫布中心兩邊必須統(tǒng)一。要是某天你把基準(zhǔn)改成圖片中心這個(gè)公式就得跟著重寫。4.3 拖拽旋轉(zhuǎn)與視圖復(fù)位旋轉(zhuǎn)不用太花哨右鍵橫向拖動(dòng)改角度加上一個(gè)鍵盤 R 鍵做 90° 步進(jìn)覆蓋絕大多數(shù)用法。void ImageCanvasWidget::setRotationFromDrag(qreal dx) { // 每拖 2 像素轉(zhuǎn) 1 度符號(hào)取反是因?yàn)槠聊?y 向下、逆時(shí)針為正 m_rotation std::fmod(m_rotation - static_castfloat(dx) * 0.5f, 360.0f); if (m_rotation 0.0f) { m_rotation 360.0f; // 歸一化到 [0, 360)避免數(shù)值越滾越大 } update(); } void ImageCanvasWidget::rotateBy(float degrees) { m_rotation std::fmod(m_rotation degrees, 360.0f); update(); } void ImageCanvasWidget::resetView() { m_scale 1.0f; m_rotation 0.0f; m_translation QVector2D(0.0f, 0.0f); update(); }std::fmod那一步是后悔藥。如果不做歸一化用戶按住右鍵轉(zhuǎn)十圈m_rotation就變成 3600浮點(diǎn)精度一點(diǎn)點(diǎn)累積轉(zhuǎn)回 0 度時(shí)畫面會(huì)有微小傾斜。歸一化之后最多只有 359 的誤差肉眼看不出來(lái)。復(fù)位按鈕也別只復(fù)位縮放——把旋轉(zhuǎn)和平移一起歸零否則用戶點(diǎn)了復(fù)位發(fā)現(xiàn)圖還是歪的體驗(yàn)很怪。5. 避坑與排查黑屏、倒像、斜條紋、泄漏這四類問(wèn)題5.1 環(huán)境與初始化類一進(jìn)來(lái)就是純黑現(xiàn)象程序能起來(lái)窗口也有但畫布區(qū)域一片黑什么都看不到日志里也沒(méi)有異常。原因絕大多數(shù)是initializeOpenGLFunctions()沒(méi)調(diào)或者 shaderlink()失敗后被靜默跳過(guò)。Qt6 里QOpenGLFunctions的函數(shù)指針要靠這個(gè)調(diào)用填充不調(diào)的話第一次glGenTextures就是空指針在部分平臺(tái)上直接崩在另一些平臺(tái)上靜默返回 0。另一個(gè)高頻原因是鏈接時(shí)只帶了Qt6::Widgets沒(méi)帶Qt6::OpenGLWidgets編譯期不報(bào)錯(cuò)運(yùn)行時(shí)構(gòu)造 QOpenGLWidget 才炸。解決把a(bǔ)ddShaderFromSourceCode和link()的返回值全部檢查失敗時(shí)用qWarning打印m_program.log()initializeGL第一行固定寫initializeOpenGLFunctions()。如果還是沒(méi)有輸出檢查QSurfaceFormat有沒(méi)有在QApplication構(gòu)造之前設(shè)置——setDefaultFormat必須在main里QApplication a(argc, argv);之前調(diào)之后調(diào)無(wú)效這是個(gè)經(jīng)典的時(shí)間窗口問(wèn)題。5.2 畫面表現(xiàn)類圖片上下顛倒或出現(xiàn)斜條紋現(xiàn)象 A圖能顯示但是上下倒著的文字也反了。原因 AQImage 原點(diǎn)在左上、GL 紋理原點(diǎn)在左下兩邊坐標(biāo)系不一致。如果只在uploadTexture里做了mirrored(false, true)卻又在頂點(diǎn)數(shù)據(jù)里把 v 坐標(biāo)寫成1.0 - v等于翻了兩次看起來(lái)又正了一旦換一種加載路徑比如從內(nèi)存 buffer 而不是文件只走其中一處翻轉(zhuǎn)立刻就顛倒。解決 A翻轉(zhuǎn)只做一次且固定在一個(gè)地方。這個(gè)項(xiàng)目的做法是統(tǒng)一在uploadTexture里翻頂點(diǎn) uv 永遠(yuǎn)寫(0,0)-(1,1)誰(shuí)改了翻轉(zhuǎn)邏輯必須同步檢查另一處?,F(xiàn)象 B圖片邊緣出現(xiàn)斜著的彩色條紋或者右下角有一片噪點(diǎn)。原因 BglPixelStorei(GL_UNPACK_ALIGNMENT, 1)沒(méi)寫驅(qū)動(dòng)按 4 字節(jié)對(duì)齊讀行寬度不是 4 的倍數(shù)的圖必然讀串行。解決 B上傳紋理前固定加一行g(shù)lPixelStorei(GL_UNPACK_ALIGNMENT, 1)。這個(gè)設(shè)置是全局狀態(tài)會(huì)一直保留但建議每次上傳前都設(shè)避免被別處改掉后踩坑。5.3 交互與數(shù)值類放大后圖片往角落跑現(xiàn)象滾輪放大幾次之后圖片不是圍繞光標(biāo)放大而是快速往右下角飛出去最后完全看不見(jiàn)或者拖拽時(shí)手感明顯黏跟手的距離對(duì)不上。原因buildMvp里少寫了最后那句反向平移mv.translate(-w/2, -h/2)縮放和旋轉(zhuǎn)就繞畫布左上角發(fā)生。拖拽黏的原因通常是單位不統(tǒng)一——e-position()返回邏輯像素而平移量如果按物理像素累加高 DPI 屏上速度就變成 2 倍。解決矩陣鏈的尾部必須補(bǔ)上把圖片中心移到原點(diǎn)這一步坐標(biāo)換算全程只用邏輯像素如果確實(shí)需要物理像素統(tǒng)一在resizeGL里乘一次devicePixelRatioF()別散落在各個(gè)事件處理里。檢查方法很簡(jiǎn)單把m_texSize打出來(lái)再打印一次鼠標(biāo)位置經(jīng)過(guò)buildMvp().inverted()映射后的世界坐標(biāo)看看是不是落在[0, w] × [0, h]里。5.4 性能與資源類連續(xù)換圖幾十次后卡死現(xiàn)象反復(fù)打開(kāi)不同的圖片內(nèi)存占用一路漲最后系統(tǒng)卡頓甚至進(jìn)程被殺或者大圖切換時(shí)有一秒多的白屏。原因glGenTextures每次換圖都新建紋理舊的沒(méi)glDeleteTextures顯存泄漏。另一種是每次paintGL都調(diào)glTexImage2D重新上傳一張 8000×6000 的 RGBA 圖每幀要拷 180MB 到顯存幀率自然崩。解決uploadTexture里先判m_texture ! 0就復(fù)用現(xiàn)有紋理 ID只在尺寸變化時(shí)重建paintGL里絕不重新上傳。如果是同一尺寸的圖頻繁刷新比如視頻幀用glTexSubImage2D替換整張glTexImage2D后者要重新分配顯存前者只覆蓋數(shù)據(jù)快一個(gè)數(shù)量級(jí)。析構(gòu)函數(shù)里記得makeCurrent()之后再glDeleteTextures直接刪會(huì)因?yàn)闆](méi)有當(dāng)前上下文而失敗。注意QOpenGLWidget在銷毀時(shí)上下文可能已經(jīng)失效~ImageCanvasWidget里刪紋理必須自己makeCurrent()Qt 不會(huì)幫你。6. 二次開(kāi)發(fā)從單圖查看器擴(kuò)到多圖層標(biāo)注器的改造思路這個(gè)畫布跑通之后最有價(jià)值的延伸是把它往多圖層方向改。單圖查看器的瓶頸在m_texture只有一個(gè)一旦要做底圖 標(biāo)注層 ROI 框選就得把紋理和矩陣抽成獨(dú)立的圖層結(jié)構(gòu)struct Layer { GLuint texture 0; QSize size; QMatrix4x4 localTransform; // 圖層自身的偏移/縮放相對(duì)畫布為恒等 float opacity 1.0f; bool visible true; }; QVectorLayer m_layers;改造的關(guān)鍵是把buildMvp拆成兩級(jí)投影矩陣proj全局一份每個(gè)圖層自己的模型矩陣單獨(dú)算繪制時(shí)proj * layer.localTransform逐個(gè)上傳。這樣拖拽平移只改全局的視圖矩陣單獨(dú)挪某個(gè)標(biāo)注框只改對(duì)應(yīng)圖層的localTransform互不干擾也不用重新上傳紋理。第二個(gè)常見(jiàn)需求是導(dǎo)出當(dāng)前視圖。不要用QPixmap::grabWidget那截的是窗口會(huì)被遮擋、被 DPI 縮放。正確做法是grabFramebuffer()它直接讀 FBO拿到的是純繪制內(nèi)容bool ImageCanvasWidget::exportView(const QString path) { makeCurrent(); // 讀 FBO 前必須綁定上下文 const QImage frame grabFramebuffer(); doneCurrent(); return frame.save(path); // 格式由后綴決定 }第三個(gè)方向是精度。如果做的是工業(yè)檢測(cè)、遙感、病理切片圖片動(dòng)輒兩萬(wàn)像素以上GL_MAX_TEXTURE_SIZE直接卡死。這時(shí)要么按 2048 或 4096 切塊每塊一張紋理按可視區(qū)域只畫可見(jiàn)的塊視錐裁剪要么把原始QImage拆成金字塔縮放級(jí)別切換時(shí)換用不同分辨率的紋理顯存占用能壓下來(lái)一大截。這兩條路的共同點(diǎn)是始終保留 CPU 側(cè)的原始 QImageGPU 上的紋理只是顯示用的副本任何需要精確像素的操作取色、測(cè)量、導(dǎo)出都回到原圖算不要從紋理讀回來(lái)。關(guān)于驗(yàn)證我的習(xí)慣是準(zhǔn)備一組刁鉆圖當(dāng)回歸樣本一張 1×1、一張 1023×769寬度不是 4 的倍數(shù)、一張帶 alpha 的 PNG、一張 20000×20000 的大圖、一張灰度圖。每次改完矩陣或者紋理上傳邏輯就把這五張挨個(gè)過(guò)一遍縮放拖到 0.05 和 30 兩個(gè)極端右鍵轉(zhuǎn)兩圈看有沒(méi)有抖動(dòng)、有沒(méi)有黑邊、邊緣有沒(méi)有斜紋。這套流程是被坑出來(lái)的血淚經(jīng)驗(yàn)——只測(cè)一張 4K 的彩色 JPG永遠(yuǎn)發(fā)現(xiàn)不了對(duì)齊和格式的問(wèn)題。從那以后我每次動(dòng)渲染相關(guān)的代碼都會(huì)在提交前把這組圖跑一遍寧可多花十分鐘也不想再收到大圖打開(kāi)是斜的這種沒(méi)有截圖、沒(méi)有復(fù)現(xiàn)步驟的反饋。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取