跳转至

现代Qt开发教程(新手篇)2.2——坐标系与 QTransform 变换基础

1. 前言 / 为什么需要坐标变换

说实话,刚开始学 QPainter 的时候,我以为只要会画矩形和圆就够了。直到有一天我需要画一个旋转 45 度的指针仪表盘,我盯着屏幕上那根斜着的线,脑子里只有一个想法:难道我要手算每个点的坐标?

你想想看,一个时钟的秒针,每秒钟旋转 6 度。如果纯用手算三角函数去算端点坐标,代码会变成什么样子?每画一个旋转图形就得写一堆 sin/cos,维护起来简直噩梦。更别提还要处理缩放和平移的组合了。

坐标变换就是为了解决这个问题而生的。你可以把坐标变换理解成"移动画布"——你不用重新计算每个点的位置,只需要告诉 QPainter"把坐标原点移到这儿"、"旋转 45 度"、"放大两倍",然后像什么都没发生一样用原来的坐标画画就行。

更直白地说:坐标变换让你在"局部坐标系"里工作,不用管"全局坐标系"是什么样。你在 (0, 0) 画一个圆,通过变换,这个圆可以出现在屏幕的任何位置、任何角度、任何大小。这就是变换的威力。

这篇文章我们搞清楚三件事:基础的 translate/rotate/scale 怎么用、save/restore 怎么管理状态、viewport 和 window 坐标映射是什么意思。

2. 环境说明

本篇代码适用于 Qt 6.5+ 版本,CMake 3.26+,C++17 或更高标准。示例代码依赖 QtGui 和 QtWidgets 模块,和上一篇一样。坐标变换是 QPainter 的一部分,不需要额外的库。

3. 核心概念讲解

3.1 translate —— 平移坐标原点

translate(dx, dy) 的作用是:把坐标原点从当前位置移动 (dx, dy) 的距离。移动之后,所有绘图操作的坐标都基于新的原点。

void paintEvent(QPaintEvent *) override
{
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    // 默认原点在左上角 (0, 0)
    painter.setPen(Qt::black);
    painter.drawRect(0, 0, 50, 50);  // 在左上角画一个 50x50 的矩形

    // 平移原点到 (100, 100)
    painter.translate(100, 100);

    // 现在原点在 (100, 100),同样的代码画出来的矩形位置不同了
    painter.setPen(Qt::red);
    painter.drawRect(0, 0, 50, 50);  // 这个矩形在 (100, 100) 位置

    // 再次平移
    painter.translate(100, 50);  // 累加!现在原点在 (200, 150)
    painter.setPen(Qt::blue);
    painter.drawRect(0, 0, 50, 50);  // 这个矩形在 (200, 150) 位置
}

这里有个非常重要的概念:变换是累加的。每次 translate 都是在当前坐标系的基础上继续偏移,不是从初始位置重新开始。这就像你给人指路:"往前走 100 米,再往右走 50 米",而不是每次都从起点开始算。

translate 最常见的用途是在循环里画重复图形:

// 在不同位置画 5 个一模一样的矩形
for (int i = 0; i < 5; ++i) {
    painter.drawRect(0, 0, 40, 40);
    painter.translate(60, 0);  // 每次往右移 60 像素
}

不用变换的话,你得手动算每个矩形的 x 坐标。用了变换,代码清爽多了。

3.2 rotate —— 旋转坐标系

rotate(angle) 的作用是:绕当前原点顺时针旋转 angle 度。注意,单位是度,不是弧度(挺好的,不用算pi什么的)

void paintEvent(QPaintEvent *) override
{
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    // 先把原点移到窗口中央
    painter.translate(width() / 2.0, height() / 2.0);

    // 画一个十字标记中心点
    painter.setPen(Qt::gray);
    painter.drawLine(-20, 0, 20, 0);
    painter.drawLine(0, -20, 0, 20);

    // 绘制旋转的矩形 —— 每次旋转 30 度
    QPen pen(Qt::blue, 2);
    painter.setPen(pen);
    painter.setBrush(QBrush(QColor(100, 150, 255, 80)));

    for (int i = 0; i < 12; ++i) {
        painter.rotate(30);          // 每次旋转 30 度
        painter.drawRect(50, -10, 80, 20);  // 离原点 50 像素处画矩形
    }
}

rotate 有个特别容易踩的坑:旋转中心是当前坐标原点,不是你画的图形的中心。如果你想让一个矩形绕自己的中心旋转,你必须先把原点移到矩形中心,然后再旋转。

// 想让一个矩形绕自己的中心旋转 45 度
// 矩形大小 100x60,中心在 (200, 150)

// 方法:先 translate 到矩形中心,再 rotate,再 translate 回去偏移一半宽高
painter.translate(200, 150);     // 把原点移到旋转中心
painter.rotate(45);              // 旋转 45 度
painter.translate(-50, -30);     // 偏移半个宽高,让矩形居中
painter.drawRect(0, 0, 100, 60);

记住这个口诀:先移到旋转中心,再旋转,再偏移回去。这个顺序不能乱,因为变换的叠加顺序和数学上的矩阵乘法一样,顺序不同结果完全不同。

3.3 scale —— 缩放坐标系

scale(sx, sy) 的作用是:对坐标轴进行缩放。sx 是 x 轴缩放比例,sy 是 y 轴缩放比例。

// 放大两倍
painter.scale(2.0, 2.0);
painter.drawRect(10, 10, 50, 50);  // 实际显示为 20, 20, 100, 100

// 水平翻转(x 轴镜像)
painter.scale(-1, 1);

// 垂直翻转(y 轴镜像)
painter.scale(1, -1);

scale 有个不太直觉的副作用:它不仅缩放坐标,还缩放画笔宽度和字体大小。如果你 scale(2, 2),原本 1 像素宽的线条会变成 2 像素,16 号字体会变成 32 号。有时候这是你要的效果,有时候不是。

如果你只想缩放图形而不缩放线条,可以在 scale 之前把画笔宽度设成期望宽度除以缩放比例:

double s = 2.0;
painter.scale(s, s);
QPen pen(Qt::black, 1.0 / s);  // 这样缩放后线条仍然是 1 物理像素
painter.setPen(pen);

3.4 save() / restore() —— 保存恢复画笔状态

前面说了变换是累加的,但很多时候你需要"试一试某个变换,然后回到之前的状态"。这时候 save()restore() 就派上用场了。

save() 会把当前 QPainter 的所有状态(画笔、画刷、变换矩阵、裁剪区域等)压入一个内部栈。restore() 从栈顶弹出并恢复之前保存的状态。

void paintEvent(QPaintEvent *) override
{
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing);

    // 初始状态:黑色画笔,原点在左上角
    painter.setPen(Qt::black);
    painter.drawText(10, 20, "原始状态");

    // ---- 第一段变换 ----
    painter.save();  // 保存当前状态
    painter.translate(100, 100);
    painter.rotate(45);
    painter.setPen(Qt::red);
    painter.setBrush(QBrush(QColor(255, 0, 0, 80)));
    painter.drawRect(-30, -30, 60, 60);
    painter.restore();  // 恢复到 save() 时的状态

    // ---- 此时回到初始状态 ----
    // 画笔又变回黑色,原点又回到左上角
    painter.drawText(10, 50, "恢复后仍然是黑色文字");

    // ---- 第二段变换 ----
    painter.save();
    painter.translate(300, 150);
    painter.scale(1.5, 1.5);
    painter.setPen(Qt::blue);
    painter.drawRect(0, 0, 60, 40);
    painter.restore();

    // ---- 又回到初始状态 ----
    painter.drawText(10, 80, "第二次恢复后还是黑色文字");
}

save/restore 可以嵌套使用,最多嵌套大约 32 层(具体取决于实现)。日常开发中 3-5 层嵌套已经很罕见了。养成一个好习惯:每次做变换之前先 save(),画完后立刻 restore()。这样你的代码不会因为变换的累加效应而变得难以理解。

3.5 视口(viewport)与窗口(window)坐标映射

这一节讲的是 QPainter 的"窗口-视口"映射机制,它听起来很学术,但实际用途很简单:让你用自己定义的坐标系来画图,而不是被迫使用像素坐标。viewport(视口)是 Widget 的物理矩形区域,单位是像素;window(窗口)是你自定义的逻辑矩形区域,单位是你自己定义的。两者之间建立映射关系后,QPainter 会自动把你的逻辑坐标转换为物理坐标。

void paintEvent(QPaintEvent *) override
{
    QPainter painter(this);

    // 设置视口:使用整个 Widget 区域(物理像素)
    painter.setViewport(0, 0, width(), height());

    // 设置窗口:逻辑坐标范围 (-100, -100) 到 (100, 100)
    // 这样原点 (0, 0) 就在窗口正中央了!
    painter.setWindow(-100, -100, 200, 200);

    // 现在坐标系变成了:
    // x 范围 [-100, 100],y 范围 [-100, 100]
    // (0, 0) 在窗口正中央
    // y 轴仍然是向下的!

    // 画一个以中心为原点的坐标系
    painter.setPen(Qt::black);
    painter.drawLine(-100, 0, 100, 0);   // x 轴
    painter.drawLine(0, -100, 0, 100);   // y 轴

    // 在第一象限画一个矩形(注意 y 轴向下)
    painter.setBrush(QBrush(QColor(100, 200, 100, 150)));
    painter.drawRect(10, -50, 40, 40);
}

window/viewport 最经典的用法是画数学图形。比如你想画一个函数图像,x 范围 [-pi, pi],y 范围 [-1, 1]。你不用手动把数学坐标换算成像素坐标,直接设 window 就行:

// 数学坐标系:x 从 -3.14 到 3.14,y 从 -1.5 到 1.5
painter.setWindow(-314, -150, 628, 300);

// 画 sin(x) 曲线
QPolygonF curve;
for (int i = -314; i <= 314; ++i) {
    double x = i / 100.0;
    double y = std::sin(x);
    curve << QPointF(i * 100, static_cast<int>(-y * 100));  // y 取反是因为屏幕 y 轴向下
}
painter.drawPolyline(curve);

说实话,window/viewport 这个功能在一般的应用开发中用得不算多,大部分时候 translate/rotate/scale 就够用了。但如果你要画地图、图表、数学图形这种需要特定坐标系的场景,这个功能能省你大量的坐标换算代码。

3.6 局部坐标系与全局坐标系

理解了变换之后,你需要建立两个概念:全局坐标系是 Widget 的原始坐标系,原点在左上角,单位是像素;局部坐标系是经过 translate/rotate/scale 变换后的坐标系。当你在局部坐标系里画 (0, 0) 的时候,这个点在全局坐标系里的位置取决于你做了什么变换。你可以用 QPainter::transform() 获取当前的变换矩阵,用 QPainter::worldTransform().map(QPointF(x, y)) 把局部坐标转换为全局坐标。

painter.translate(100, 50);
painter.rotate(30);

// 局部坐标系里的 (0, 0),在全局坐标系里是哪?
QPointF globalPos = painter.worldTransform().map(QPointF(0, 0));
qDebug() << "全局坐标:" << globalPos;  // 大约 (100, 50)

// 局部坐标系里的 (50, 0),经过 translate + rotate 后的全局坐标
QPointF globalPos2 = painter.worldTransform().map(QPointF(50, 0));
qDebug() << "全局坐标:" << globalPos2;

实际开发中,最常见的需求是"把全局坐标转换成局部坐标"——比如鼠标点击事件给你的是全局坐标,但你想知道它对应到画面的哪个位置。这时候需要用逆变换:

void mousePressEvent(QMouseEvent *event) override
{
    QPainter painter(this);
    // ... 设置各种变换 ...
    // 把鼠标的窗口坐标转换到局部坐标
    QPointF localPos = painter.worldTransform().inverted().map(event->position());
    qDebug() << "局部坐标:" << localPos;
}

到这里你可以停下来想一想:translate、rotate、scale 分别改变了坐标系的什么?如果你想让一个矩形绕自身的中心旋转,代码的执行顺序应该是什么样的?搞清楚这些问题,后面写复杂绘图代码就不会手忙脚乱了。

4. 踩坑预防

坐标变换这块的坑还真不少,我们逐个来说。

第一个坑,也是最经典的:rotate 的旋转中心不是图形中心。很多人写了 painter.rotate(45) 再画矩形,发现矩形飞到屏幕外面去了,以为没画出来。实际上 rotate 是绕当前坐标原点旋转的,不是绕你画的图形的中心。如果你想绕图形自身中心旋转,必须先 translate 到图形中心,再 rotate,再偏移回去半个宽高。这个顺序不能乱——先做什么后做什么直接影响结果,因为变换叠加的顺序和矩阵乘法一样,顺序不同结果完全不同:

// 错误:直接 rotate,图形绕原点旋转飞走了
painter.rotate(45);
painter.drawRect(100, 100, 80, 60);  // 不是绕矩形中心旋转!是绕原点旋转

// 正确:先 translate 到旋转中心,再 rotate,再偏移回去
painter.translate(140, 130);  // 移到矩形中心 (100+40, 100+30)
painter.rotate(45);
painter.translate(-40, -30);  // 偏移半个宽高
painter.drawRect(0, 0, 80, 60);

第二个坑是忘记 save/restore 导致变换累加。这个坑特别隐蔽,因为它不会立刻报错,而是让你的图形位置和角度变得完全不可预测。比如你在循环里不断 translate 和 rotate,但没有用 save/restore 重置状态,每循环一次,偏移量和旋转角度就累加一次。到最后你完全不知道原点跑哪去了。解决方法很简单:每次做变换前 save(),画完后 restore(),养成习惯就好:

// 错误:循环里不断累加
for (int i = 0; i < 10; ++i) {
    painter.translate(50, 0);   // 每次累加 50 像素!
    painter.rotate(15);         // 每次累加 15 度!
    painter.drawRect(0, 0, 30, 30);
}
// 循环结束后,原点已经偏移了 500 像素,旋转了 150 度

// 正确:每次循环内 save/restore
for (int i = 0; i < 10; ++i) {
    painter.save();
    painter.translate(50 + i * 60, 100);
    painter.rotate(i * 15);
    painter.drawRect(-15, -15, 30, 30);
    painter.restore();
}

第三个坑是 scale 会影响画笔宽度和字体大小。scale(3, 3) 之后你会发现线条粗得离谱、文字大得吓人,整个画面一团糊。这是因为 scale 缩放的是整个坐标系,线宽和字号也跟着缩放了。如果你只想缩放图形本身而保持线宽不变,需要手动补偿:把画笔宽度设成期望值除以缩放比例,字体大小同理:

double s = 3.0;
painter.scale(s, s);
QPen pen(Qt::black, 1.0 / s);  // 缩放后仍然是 1 像素
painter.setPen(pen);
QFont font("Arial", 16.0 / s);  // 缩放后仍然是 16 号
painter.setFont(font);

第四个坑是 setWindow 的参数理解错误。setWindow(x, y, w, h) 的后两个参数是宽度和高度,不是右下角坐标——这和 drawRect 的参数约定是一样的,但还是有很多人搞混。比如你想让坐标范围是 [-50, 50],正确的写法是 setWindow(-50, -50, 100, 100),因为宽度是 100。如果你写成 setWindow(-50, -50, 50, 50),范围就变成了 [-50, 0],画出来的图形位置完全不对或者只显示一部分。

接下来做一个代码填空练习:补全下面的代码,在窗口中央画一个旋转 30 度的矩形:

void paintEvent(QPaintEvent *) override
{
    QPainter painter(this);
    painter.setRenderHint(QPainter::________);  // 1. 抗锯齿提示

    // 先保存状态
    painter.________();  // 2. 保存当前状态

    // 移动原点到窗口中央
    painter.________(width() / 2.0, height() / 2.0);  // 3. 平移

    // 旋转 30 度
    painter.________(30);  // 4. 旋转

    // 偏移半个矩形大小,使矩形居中
    painter.translate(________, -25);  // 5. 矩形宽 100,偏移量是多少?

    painter.setPen(QPen(Qt::red, 2));
    painter.setBrush(QBrush(QColor(255, 100, 100, 150)));
    painter.drawRect(0, 0, 100, 50);

    // 恢复状态
    painter.________();  // 6. 恢复之前保存的状态
}

再来一道调试挑战:这段代码想画一个绕自身中心旋转的矩形,但运行后发现矩形飞到了窗口外面。问题出在哪里?

void paintEvent(QPaintEvent *) override
{
    QPainter painter(this);
    painter.rotate(45);
    painter.translate(200, 150);
    painter.drawRect(-40, -30, 80, 60);
}

提示:想想变换的执行顺序和累加效果。

5. 练习项目

我们来做一个实战练习:创建一个模拟时钟 Widget,显示当前时间的时、分、秒针。秒针每秒更新一次,使用 QTimer 驱动。

完成标准是:自定义 AnalogClockWidget 继承 QWidget;使用 translate 把原点移到窗口中央;用 rotate 旋转不同角度来画时针、分针、秒针;用 QTimer 每秒触发 update() 刷新画面;画表盘包括圆形外框和 12 个刻度标记;时针短粗、分针中等、秒针细长,颜色区分明显;save/restore 正确使用,每次画指针前 save,画完后 restore。

几个提示:用 QTime::currentTime() 获取当前时间;秒针角度等于秒数乘以 6(每秒 6 度);分针角度等于分钟加秒除以 60 再乘以 6;时针角度等于小时对 12 取模加分钟除以 60 再乘以 30;画指针时先 translate 到中心,再 rotate 对应角度,再画一条从原点向上的线(注意 y 轴向下,所以向上是负方向)。

6. 官方文档参考链接

Qt 文档 · QPainter Coordinate System -- Qt 坐标系统的完整说明,涵盖逻辑坐标、物理坐标、变换矩阵

Qt 文档 · QTransform Class -- 变换矩阵类的详细文档,支持平移、旋转、缩放、剪切等仿射变换

Qt 文档 · QPainter::save/restore -- 状态栈的保存与恢复机制说明

Qt 文档 · Analog Clock Example -- Qt 官方的模拟时钟示例,非常好的坐标变换学习参考


到这里,坐标变换的基础你应该掌握了。核心就三件事:translate 移原点、rotate 绕原点转、scale 缩放坐标——加上 save/restore 管理状态。变换的顺序很重要,先做什么后做什么会直接影响结果。下一篇文章我们换一个话题,聊聊 Qt 里的图像处理:QImage、QPixmap 和 QIcon。