Qt中提供了强大的 2D 绘图系统,可以使用相同的API在屏幕和绘图设备上进行绘制,主要基于 QPainter、QPaintDevice和 QPaintEngine 这三个类实现,其中:

  • QPainter执行具体的绘图操作,QPainter 类提供了大量的GUI编程所需的函数,如drawLine、drawImage等。
  • QPaintDevice 是一个基类,提供绘图设备的抽象接口,它是一个二维空间的抽象,可以使用QPainter在其上进行绘制,常见的继承该类的绘图设备有QImage、QPixmap、QWidget、QOpenGLPaintDevice等。
  • QPaintEngine 提供了一些抽象接口,用于实现QPainter在不同的设备上进行绘制。QPaintEngine 由 QPaintDevice 创建并管理。

在Qt官方文档中有一张图可以很好说明Qt各个坐标系变换方式(暂不用关注图上的坐标值):

Qt坐标关系图

我们在使用QPainter绘图时需要传入元素坐标,传入的坐标就是图上的世界坐标系(World Coordinates),世界坐标经过矩阵变换(这一步是可选的)后再传递给窗口坐标系(Window Coordinates),窗口坐标系上的元素的坐标还需经过窗口到视口的坐标变换(线性变换),最后在设备坐标系(Device Coordinates)的视口区域内绘制。

1. 世界坐标系

世界坐标系也称之为逻辑坐标系,使用的单位叫逻辑单位。

在真实的世界地理中,我们通常使用经纬度来表示某一个具体位置,如武汉的坐标为(东经114°,北纬30°),而在Qt绘图时使用的逻辑单位是像素。

Qt的像素支持非整数数量,如 0.5 个像素,而实际设备(如显示器、打印机等)只支持整数数量的像素显示,因此从逻辑坐标到设备坐标需要经过相应的转换过程。

在Qt中,我们提供给 QPainter 的都是逻辑坐标,这种坐标与具体的设备类型无关。

2. 窗口坐标系

上图中的窗口是打了引号的,这说明该窗口是实际不存在的,这个窗口指的是窗口坐标系左上角原点到右下角的一个虚拟矩形窗口。

默认情况下,窗口坐标系和逻辑坐标系都等同于设备坐标系,三者重合,但我们可以使用QPaintet::setWindow方法重设窗口坐标系的原点和范围,有了左上角原点和长/宽,也就得到一个新的“窗口”。

在绘图时传递给 QPainter 的逻辑坐标需要先经过矩阵变换(常见的矩阵变换有QPainter 的 translate、scale、rotate、shear等方法,后面章节会介绍),然后再传递给窗口坐标系。

3. 设备坐标系

也称之为物理坐标(Physical Coordinates),坐标原点位于左上角,X轴水平向右增长,Y轴垂直向下增长。

不同的设备坐标单位通常不同,如显示器通常以像素为单位,而打印机通常以点为单位。

需要注意的是设备坐标系的原点和范围始终等同于实际绘制设备,不会跟随逻辑坐标系、窗口坐标系以及视口(viewport)的改变而改变。

4. 视口

视口(viewport)是设备环境中的一个矩形框,使用设备坐标系表示。默认情况下,视口左上角位置为设备坐标系的原点,大小为实际绘制目标设备的大小。

视口存在的意义是为了指定在显示设备的什么位置,以多大的范围来完全显示指定的窗口内容,如果窗口内容超出了视口区域,超出部分将不会显示。

我们可以用 QPainter::setViewport 方法来改变视口的位置和大小,QPainter::setViewport 的参数都是基于设备坐标系的,如果指定的视口区域超出了设备外,设备外的内容将不会显示。

5. 实例讲解

为了更好说明各个坐标系及视口之间的关系,我们通过一个实例来具体说明。首先创建一个长宽为400x400的窗口,并且程序未开启DPI缩放特性:

1
resize(400, 400);

绘制代码如下:

1
2
3
4
5
6
7
void Qt2DSample::paintEvent(QPaintEvent* e) {
QPainter painter(this);
painter.translate(100, 200);
painter.setWindow(QRect(200, 300, 600, 600));
painter.setViewport(QRect(100, 100, 300, 300));
painter.drawRect(QRect(300, 100, 200, 200));
}

下图使用不同的颜色绘制了不同的坐标系及视口、窗口的位置和区域:

第1行代码QPainter painter(this)

表明我们是在当前 QWidget 实例上进行绘制,因此设备坐标系原点为(0,0),坐标系的范围为QWidget的长宽400x400,坐标单位为像素,对应上图绿色部分。

此时还未设置视口,因此视口默认等同于设备坐标系的范围,即(0,0,400,400),该部分未在上图画出。

第2行代码painter.translate(100, 200):

将逻辑坐标系的原点偏移(100,200)个像素。

在进行偏移时,值为正表示向方向移动,值为负表示向负方向移动。

第3行代码painter.setWindow(QRect(200, 300, 600, 600))

设置窗口坐标系原点为(200, 300),窗口范围是600x600,对应上图红色部分。

第4行代码painter.setViewport(QRect(100, 100, 300, 300))

设置视口为QRect(100, 100, 300, 300),即在设备坐标系上的使用QRect(100, 100, 300, 300)区域来显示上面600x600的窗口,对应上图蓝色部分。

第2、3、4行代码是没有先后顺序的,因为三个设置函数所影响的目标是不同的。

第5行代码painter.drawRect(QRect(300, 100, 200, 200))

在 (300,100) 位置绘制 200x200 的正方形,此处传递的是逻辑坐标,而逻辑坐标系已在第2行代码处进行了偏移,因此根据新的逻辑坐标系,正方形左上角位置是 (300+100, 100+200) = (400, 300),又因为此处逻辑坐标系未做缩放操作(缩放操作由 QPainter::scale 提供),因此正方形的长宽保持不变,仍为200x200,即 QRect(400,300,200,200)。

然后将正方形 QRect(400,300,200,200) 传递给窗口坐标系,该正方形在窗口坐标系 (200,300,600,600) 上面的位置和大小对应上图粉红色部分。

最后用设备坐标系上的视口区域来显示该窗口,由于窗口坐标系和设备坐标系的原点及范围不相同,因此还需要做线性转化:

1
2
3
4
5
在(200, 300, 600, 600) 范围上显示的正方形 (400, 300, 200, 200)

转化为

在视口(100, 100, 300, 300) 范围上显示的正方形 (x, y, w, h)

从上面可以看出窗口范围是视口的600 / 300 = 2倍,因此在视口上的原点和长宽分别为:

1
2
3
4
5
6
7
x = 100 + (400 - 200) / 2 = 200

y = 100 + (300 - 300) / 2 = 100

w = 200 / 2 = 100

h = 200 / 2 = 100

计算得到需要在视口 (100, 100, 300, 300) 上绘制的正方形为 (200,100,100,100) ,实际绘制的正方形如下图所示:

6. 移动原点到窗口中央

默认情况下,坐标原点在窗口的左上角,X轴水平向右增长,Y轴垂直向下增长。现在将坐标原点移动到窗口中央,但不改变X轴和Y轴的方向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Qt2DSample窗口长宽为400x400
void Qt2DSample::paintEvent(QPaintEvent* e) {
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing);

const int w = width();
const int h = height();

// 将坐标原点移动到窗口中央
painter.translate(w / 2.0, h / 2.0);

// 也可以使用下面的方式:
// painter.setWindow(w / 2.0, h / 2.0, w, h);
// painter.translate(w, h);

// 绘制测试矩形
painter.drawRect(QRect(0, 0, 100, 100));
}

实际绘制的矩形在窗口中的位置如下图所示:

7. QRect遗留问题

在介绍 QRect 的遗留问题之前,我们需要先了解Qt中锯齿与抗锯齿的相关内容。

7.1 抗锯齿

下图展示了在绘制一条斜线时,未开启抗锯齿(左边)和开启了抗锯齿后(右边)两者的效果。

抗锯齿就是绘图引擎通过在元素的边缘填充不同的颜色来实现平滑边缘的效果。

Qt默认绘图的元素是有锯齿的,当使用1像素宽的画笔绘制时,实际像素将渲染到我们定义坐标的右下方,如下图所示:

当画笔宽度为偶数时,像素将围绕定义坐标进行对称渲染,如下图所示:

当画笔宽度为奇数时,整除后多余的1像素将渲染到定义坐标的右下方,如下图所示:

7.2 QRect实际宽高

基于上述原因,QRect::right()QRect::bottom() 的值并不等于矩形真正右下角的坐标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
QRect::QRect(int aleft, int atop, int awidth, int aheight) noexcept
: x1(aleft), y1(atop), x2(aleft + awidth - 1), y2(atop + aheight - 1) {}

void QRect::setWidth(int w) noexcept
{ x2 = (x1 + w - 1); }

void QRect::setHeight(int h) noexcept
{ y2 = (y1 + h - 1); }

int QRect::width() const noexcept
{ return x2 - x1 + 1; }

int QRect::height() const noexcept
{ return y2 - y1 + 1; }

从上面 QRect 的源码可知,QRect::width() 和 QRect::height() 方法返回的是矩形的实际宽高,但 QRect::right() 和 QRect::bottom() 方法返回的不是矩形的实际右边和底部:

1
2
3
QRect::right() 返回的实际是 QRect::left() + QRect::width() - 1

QRect::bottom() 返回的实际是 QRect::top() + QRect::height() - 1

这属于QRect的历史遗留问题,Qt为了兼容之前老的代码,对该部分未作修改,我们可以使用下面方式获取QRect真实的宽和高:

1
2
3
QRect::x() + QRect::width()

QRect::y() + QRect::height()

也可以使用 QRectF类型来代替 QRect 类型,QRectF类型没有上述问题。

7.3 开启抗锯齿

在Qt中开启抗锯齿也非常简单,下面代码开启了抗锯齿:

1
2
3
4
painter.setRenderHints(
QPainter::Antialiasing | // 消除基础元素(如线、点)边缘的锯齿
QPainter::TextAntialiasing // 消除文字边缘的锯齿
);

开启抗锯齿以后,像素将始终围绕定义坐标进行对称渲染,并且会自动在边缘填充颜色来平滑边缘,如下图所示:

8. 逻辑坐标矩阵变换

Qt 提供了方法来直接对世界坐标(也可称为逻辑坐标)进行矩阵变换:

1
void QPainter::setWorldTransform(const QTransform &matrix, bool combine = false)

还提供方法来开启和关闭世界坐标的矩阵变换操作:

1
void QPainter::setWorldMatrixEnabled(bool enable)

同时 QPainter 还是提供了 translate、scale、rotate、shear 等快捷方法实现对逻辑坐标的矩阵变换操作。

需要注意的是在调用了translate、scale、rotate、shear方法后,QPainter 会自动开启对世界坐标的矩阵转换。

为了更好的介绍是逻辑坐标的转化过程,现在假设在坐标变换之前存在 点A(x, y) 和 矩形B(x, y, w, h)

8.1 translate

1
2
3
void QPainter::translate(const QPointF &offset)
void QPainter::translate(const QPoint &offset)
void QPainter::translate(qreal dx, qreal dy)

X和Y轴分别平移距离 m 和 n,平移后,点A的坐标为(x + m, y + n),矩形B的坐标为(x + m, y + n, w, h)

m 和 n 可以为负数。

8.2 scale

1
void QPainter::scale(qreal sx, qreal sy)

X和Y轴分别缩放 m 和 n 倍 ,缩放后,点A的坐标为(x * m, y * n),矩形B的坐标为 (x * m, y * n, w * m, h * n)

m 和 n 可以为负数,如:

点A(50,100) -> scale(-10, 2) -> (-500,200)

点A(-50,100) -> scale(-10, 2) -> (500,200)

8.3 rotate

1
void QPainter::rotate(qreal angle)

按逆时针将X和Y轴同时旋转angle度(注意angle参数的单位是度,而不是弧度),其中0度为正三点钟方向。

对于单个点进行旋转是没有意义的,

8.4 shear

1
void QPainter::shear(qreal sh, qreal sv)

对X和Y轴分别进行扭曲变换。


限于政策原因,在您看到该文章时,博客可能已经关闭了评论功能🥺

您可以通过在 blog-comment 项目中提交Issue来间接地发表评论🍀