#(28):坐標系統
在經歷過實際操作,以及前面一節中我們見到的那個`translate()`函數之后,我們可以詳細了解下 Qt 的坐標系統了。泛泛而談坐標系統,有時候會覺得枯燥無味,難以理解,好在現在我們已經有了基礎。
坐標系統是由`QPainter`控制的。我們前面說過,`QPaintDevice`、`QPaintEngine`和`QPainter`是 Qt 繪制系統的三個核心類。`QPainter`用于進行繪制的實際操作;`QPaintDevice`是那些能夠讓`QPainter`進行繪制的“東西”(準確的術語叫做,二維空間)的抽象層(其子類有`QWidget`、`QPixmap`、`QPicture`、`QImage`和`QPrinter`等);`QPaintEngine`提供供`QPainter`使用的用于在不同設備上繪制的統一的接口。
由于`QPaintDeice`是進行繪制的對象,因此,所謂坐標系統,也就是`QPaintDevice`上面的坐標。默認坐標系統位于設備的左上角,也就是坐標原點 (0, 0)。x 軸方向向右;y 軸方向向下。在基于像素的設備上(比如顯示器),坐標的默認單位是像素,在打印機上則是點(1/72 英寸)。
將`QPainter`的邏輯坐標與`QPaintDevice`的物理坐標進行映射的工作,是由`QPainter`的變換矩陣(transformation matrix)、視口(viewport)和窗口(window)完成的。如果你不理解這些術語,可以簡單了解下有關圖形學的內容。實際上,對圖形的操作,底層的數學都是進行的矩陣變換、相乘等運算。
在 Qt 的坐標系統中,每個像素占據 1×1 的空間。你可以把它想象成一張方格紙,每個小格都是1個像素。方格的焦點定義了坐標,也就是說,像素 (x, y) 的中心位置其實是在 (x + 0.5, y + 0.5) 的位置上。這個坐標系統實際上是一個“半像素坐標系”。我們可以通過下面的示意圖來理解這種坐標系:
[](http://files.devbean.net/images/2012/11/coordinate-system-demo.png)
我們使用一個像素的畫筆進行繪制,可以看到,每一個繪制像素都是以坐標點為中心的矩形。**注意,這是坐標的邏輯表示,實際繪制則與此不同。**因為在實際設備上,像素是最小單位,我們不能像上面一樣,在兩個像素之間進行繪制。所以在實際繪制時,Qt 的定義是,繪制點所在像素是邏輯定義點的右下方的像素。
我們前面已經介紹過,Qt 的繪制分為走樣和反走樣兩種。對此,我們必須分別對待。
一個像素的繪制最簡單,我們從這里開始:
[](http://files.devbean.net/images/2012/11/1px-painting.png)
從上圖可以看出,當我們繪制矩形左上角 (1, 2) 時,實際繪制的像素是在右下方。
當繪制大于1個像素時,情況比較復雜:如果繪制像素是偶數,則實際繪制會包裹住邏輯坐標值;如果是奇數,則是包裹住邏輯坐標值,再加上右下角一個像素的偏移。具體請看下面的圖示:
[](http://files.devbean.net/images/2012/11/mutilpx-painting.png)
從上圖可以看出,如果實際繪制是偶數像素,則會將邏輯坐標值夾在相等的兩部分像素之間;如果是奇數,則會在右下方多出一個像素。
Qt 的這種處理,帶來的一個問題是,我們可能獲取不到真實的坐標值。由于歷史原因,`QRect::right()`和`QRect::bottom()`的返回值并不是矩形右下角點的真實坐標值:`QRect::right()`返回的是 left() + width() – 1;`QRect::bottom()`則返回 top() + height() – 1,上圖的綠色點指出了這兩個函數的返回點的坐標。
為避免這個問題,我們建議是使用`QRectF`。`QRectF`使用浮點值,而不是整數值,來描述坐標。這個類的兩個函數`QRectF::right()`和`QRectF::bottom()`是正確的。如果你不得不使用`QRect`,那么可以利用 x() + width() 和 y() + height() 來替代 right() 和 bottom() 函數。
對于反走樣,實際繪制會包裹住邏輯坐標值:
[](http://files.devbean.net/images/2012/11/anti-aliasing-painting.png)
這里我們不去解釋為什么在反走樣是,像素顏色不是一致的,這是由于反走樣算法導致,已經超出本節的內容。
Qt 同樣提供了坐標變換。前面說,圖形學大部分算法依賴于矩陣計算,坐標變換便是其中的代表:每一種變換都對應著一個矩陣乘法(如果你想知道學的線性代數有什么用處,這就是應用之一了 ;-P)。我們會以一個實際的例子來了解坐標變換。在此之前,我們需要了解兩個函數:`QPainter::save()`和`QPainter::restore()`。
前面說過,`QPainter`是一個狀態機。那么,有時我想保存下當前的狀態:當我臨時繪制某些圖像時,就可能想這么做。當然,我們有最原始的辦法:將可能改變的狀態,比如畫筆顏色、粗細等,在臨時繪制結束之后再全部恢復。對此,`QPainter`提供了內置的函數:`save()`和`restore()`。`save()`就是保存下當前狀態;`restore()`則恢復上一次保存的結果。這兩個函數**必須**成對出現:`QPainter`使用棧來保存數據,每一次`save()`,將當前狀態壓入棧頂,`restore()`則彈出棧頂進行恢復。
在了解了這兩個函數之后,我們就可以進行示例代碼了:
~~~
void PaintDemo::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.fillRect(10, 10, 50, 100, Qt::red);
painter.save();
painter.translate(100, 0); // 向右平移 100px
painter.fillRect(10, 10, 50, 100, Qt::yellow);
painter.restore();
painter.save();
painter.translate(300, 0); // 向右平移 300px
painter.rotate(30); // 順時針旋轉 30 度
painter.fillRect(10, 10, 50, 100, Qt::green);
painter.restore();
painter.save();
painter.translate(400, 0); // 向右平移 400px
painter.scale(2, 3); // 橫坐標單位放大 2 倍,縱坐標放大 3 倍
painter.fillRect(10, 10, 50, 100, Qt::blue);
painter.restore();
painter.save();
painter.translate(600, 0); // 向右平移 600px
painter.shear(0, 1); // 橫向不變,縱向扭曲 1 倍
painter.fillRect(10, 10, 50, 100, Qt::cyan);
painter.restore();
}
~~~
Qt 提供了四種坐標變換:平移 translate,旋轉 rotate,縮放 scale 和扭曲 shear。在這段代碼中,我們首先在 (10, 10) 點繪制一個紅色的 50×100 矩形。保存當前狀態,將坐標系平移到 (100, 0),繪制一個黃色的矩形。注意,`translate()`操作平移的是坐標系,不是矩形。因此,我們還是在 (10, 10) 點繪制一個 50×100 矩形,現在,它跑到了右側的位置。然后恢復先前狀態,也就是把坐標系重新設為默認坐標系(相當于進行`translate(-100, 0)`),再進行下面的操作。之后也是類似的。由于我們只是保存了默認坐標系的狀態,因此我們之后的`translate()`橫坐標值必須增加,否則就會覆蓋掉前面的圖形。所有這些操作都是針對坐標系的,因此在繪制時,我們提供的矩形的坐標參數都是不變的。
運行結果如下:
[](http://files.devbean.net/images/2012/11/coordinate-transformations-demo.png)
Qt 的坐標分為邏輯坐標和物理坐標。在我們繪制時,提供給`QPainter`的都是邏輯坐標。之前我們看到的坐標變換,也是針對邏輯坐標的。所謂物理坐標,就是繪制底層`QPaintDevice`的坐標。單單只有邏輯坐標,我們是不能在設備上進行繪制的。要想在設備上繪制,必須提供設備認識的物理坐標。Qt 使用 viewport-window 機制將我們提供的邏輯坐標轉換成繪制設備使用的物理坐標,方法是,在邏輯坐標和物理坐標之間提供一層“窗口”坐標。視口是由任意矩形指定的物理坐標;窗口則是該矩形的邏輯坐標表示。默認情況下,物理坐標和邏輯坐標是一致的,都等于設備矩形。
視口坐標(也就是物理坐標)和窗口坐標是一個簡單的線性變換。比如一個 400×400 的窗口,我們添加如下代碼:
~~~
void PaintDemo::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setWindow(0, 0, 200, 200);
painter.fillRect(0, 0, 200, 200, Qt::red);
}
~~~
我們將窗口矩形設置為左上角坐標為 (0, 0),長和寬都是 200px。此時,坐標原點不變,還是左上角,但是,對于原來的 (400, 400) 點,新的窗口坐標是 (200, 200)。我們可以理解成,邏輯坐標被“重新分配”。這有點類似于`translate()`,但是,`translate()`函數只是簡單地將坐標原點重新設置,而`setWindow()`則是將整個坐標系進行了修改。這段代碼的運行結果是將整個窗口進行了填充。
試比較下面兩行代碼的區別(還是 400×400 的窗口):
~~~
painter.translate(200, 200);
painter.setWindow(-160, -320, 320, 640);
~~~
第一行代碼,我們將坐標原點設置到 (200, 200) 處,橫坐標范圍是 [-200, 200],縱坐標范圍是 [-200, 200]。第二行代碼,坐標原點也是在窗口正中心,但是,我們將物理寬 400px 映射成窗口寬 320px,物理高 400px 映射成窗口高 640px,此時,橫坐標范圍是 [-160, 160],縱坐標范圍是 [-320, 320]。這種變換是簡單的線性變換。假設原來有個點坐標是 (64, 60),那么新的窗口坐標下對應的坐標應該是 ((-160 + 64 * 320 / 400), (-320 + 60 * 640 / 400)) = (-108.8, -224)。
下面我們再來理解下視口的含義。還是以一段代碼為例:
~~~
void PaintDemo::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setViewport(0, 0, 200, 200);
painter.fillRect(0, 0, 200, 200, Qt::red);
}
~~~
這段代碼和前面一樣,只是把`setWindow()`換成了`setViewport()`。前面我們說過,window 代表窗口坐標,viewport 代表物理坐標。也就是說,我們將物理坐標區域定義為左上角位于 (0, 0),長高都是 200px 的矩形。然后還是繪制和上面一樣的矩形。如果你認為運行結果是 1/4 窗口被填充,那就錯了。實際是只有 1/16 的窗口被填充。這是由于,我們修改了物理坐標,但是沒有修改相應的窗口坐標。默認的邏輯坐標范圍是左上角坐標為 (0, 0),長寬都是 400px 的矩形。當我們將物理坐標修改為左上角位于 (0, 0),長高都是 200px 的矩形時,窗口坐標范圍不變,也就是說,我們將物理寬 200px 映射成窗口寬 400px,物理高 200px 映射成窗口高 400px,所以,原始點 (200, 200) 的坐標變成了 ((0 + 200 * 200 / 400), (0 + 200 * 200 / 400)) = (100, 100)。
現在我們可以用一張圖示總結一下邏輯坐標、窗口坐標和物理坐標之間的關系:
[](http://files.devbean.net/images/2012/11/coordinate-system-instruction.png)
我們傳給`QPainter`的是邏輯坐標(也稱為世界坐標),邏輯坐標可以通過變換矩陣轉換成窗口坐標,窗口坐標通過 window-viewport 轉換成物理坐標(也就是設備坐標)。
- (1)序
- (2)Qt 簡介
- (3)Hello, world!
- (4)信號槽
- (5)自定義信號槽
- (6)Qt 模塊簡介
- (7)MainWindow 簡介
- (8)添加動作
- (9)資源文件
- (10)對象模型
- (11)布局管理器
- (12)菜單欄、工具欄和狀態欄
- (13)對話框簡介
- (14)對話框數據傳遞
- (15)標準對話框 QMessageBox
- (16)深入 Qt5 信號槽新語法
- (17)文件對話框
- (18)事件
- (19)事件的接受與忽略
- (21)事件過濾器
- (22)事件總結
- (23)自定義事件
- (24)Qt 繪制系統簡介
- (25)畫刷和畫筆
- (26)反走樣
- (27)漸變
- (28)坐標系統
- (29)繪制設備
- (30)Graphics View Framework
- (31)貪吃蛇游戲(1)
- (32)貪吃蛇游戲(2)
- (33)貪吃蛇游戲(3)
- (34)貪吃蛇游戲(4)
- (35)文件
- (36)二進制文件讀寫
- (37)文本文件讀寫
- (38)存儲容器
- (39)遍歷容器
- (40)隱式數據共享
- (41)model/view 架構
- (42)QListWidget、QTreeWidget 和 QTableWidget
- (43)QStringListModel
- (44)QFileSystemModel
- (45)模型
- (46)視圖和委托
- (47)視圖選擇
- (48)QSortFilterProxyModel
- (49)自定義只讀模型
- (50)自定義可編輯模型
- (51)布爾表達式樹模型
- (52)使用拖放
- (53)自定義拖放數據
- (54)剪貼板
- (55)數據庫操作
- (56)使用模型操作數據庫
- (57)可視化顯示數據庫數據
- (58)編輯數據庫外鍵
- (59)使用流處理 XML
- (60)使用 DOM 處理 XML
- (61)使用 SAX 處理 XML
- (62)保存 XML
- (63)使用 QJson 處理 JSON
- (64)使用 QJsonDocument 處理 JSON
- (65)訪問網絡(1)
- (66)訪問網絡(2)
- (67)訪問網絡(3)
- (68)訪問網絡(4)
- (69)進程
- (70)進程間通信
- (71)線程簡介
- (72)線程和事件循環
- (73)Qt 線程相關類
- (74)線程和 QObject
- (75)線程總結
- (76)QML 和 QtQuick 2
- (77)QML 語法
- (78)QML 基本元素
- (79)QML 組件
- (80)定位器
- (81)元素布局
- (82)輸入元素
- (83)Qt Quick Controls
- (84)Repeater
- (85)動態視圖
- (86)視圖代理
- (87)模型-視圖高級技術
- (88)Canvas
- (89)Canvas(續)