做 CAD 类工具,滚轮缩放看着是最没技术含量的功能,但真正写起来,它往往是第一个把视图坐标系搞乱的地方。前面几篇里我已经把画布、世界坐标、变换栈搭起来了,这一篇专门处理缩放。目标很明确:滚轮滚动时,鼠标指着的那个点在屏幕上不动,视图围绕它放大或缩小。听起来一句话,实际写的时候我改了三次才稳定下来,问题全都出在矩阵乘法的顺序上。
先把坐标系说清楚
在动手之前,必须先把三个坐标空间定死,否则后面全是玄学。
- 屏幕坐标(screen):鼠标事件给的坐标,单位是像素,原点在控件左上角。
- 世界坐标(world):图形数据本身的坐标,单位由业务决定,可以是毫米,也可以是任意单位。
- 视图变换(view transform):从 world 到 screen 的映射,用一个 3x3 矩阵表示。
这里我用 3x3 齐次矩阵,而不是 2x3。原因是后面如果要加旋转、斜切,3x3 不用改结构,只是多几个非零元素。Python 里我用 numpy 存矩阵,形状固定 (3, 3),dtype=float64。
python
import numpy as np
def identity():
return np.eye(3, dtype=np.float64)
def translation(tx, ty):
m = np.eye(3, dtype=np.float64)
m[0, 2] = tx
m[1, 2] = ty
return m
def scaling(sx, sy):
m = np.eye(3, dtype=np.float64)
m[0, 0] = sx
m[1, 1] = sy
return m
约定:点用列向量 [x, y, 1]^T,变换写成 p_screen = M_view @ p_world。这个约定一旦定了,就不要中途改成行向量,否则每一处乘法都要重新想一遍。
从鼠标位置反推世界坐标

要做"以鼠标为中心",第一步是把鼠标的屏幕坐标换算到世界坐标。因为 p_screen = M_view @ p_world,反解就是:
python
def screen_to_world(m_view, sx, sy):
inv = np.linalg.inv(m_view)
p = inv @ np.array([sx, sy, 1.0], dtype=np.float64)
return p[0] / p[2], p[1] / p[2]
这里除以 p[2] 是齐次坐标归一化。当前变换没有投影,p[2] 恒等于 1,但保留这一步,后面如果引入透视投影就不用改。
拿到鼠标对应的世界点 (wx, wy) 之后,缩放的目标就很清晰:缩放前后,这个点投影回屏幕,位置必须还是 (sx, sy)。
缩放矩阵该往哪边乘

这是整个过程里最容易出错的地方。我一开始的想法很朴素:既然要围绕鼠标缩放,那就先平移到鼠标,再缩放,再平移回去。写成矩阵就是:
python
M_new = M_view @ translation(wx, wy) @ scaling(k, k) @ translation(-wx, -wy)
跑起来之后发现,视图确实在缩放,但鼠标指的那个点会缓慢漂移。缩得越大,偏得越离谱。我对着结果盯了半天,才意识到问题出在"在哪个空间里做平移"上。
关键点是:translation(wx, wy) 里的 wx, wy 是世界坐标 ,所以这个平移必须作用在世界的右侧;而它左边乘的 M_view 会把结果再映射到屏幕。这个写法本身没错,但它描述的是"在世界空间里,围绕世界点 (wx, wy) 缩放"。如果鼠标的世界坐标算得准,它确实应该是对的。
那为什么会漂?我打印了几组数据,发现问题不在矩阵,而在我把 M_view 的更新写成了原地累积:
python
# 错误写法:把新矩阵当作"增量"继续往上乘
m_view = m_view @ translation(wx, wy) @ scaling(k, k) @ translation(-wx, -wy)
这样写等于每次缩放都基于"已经包含了上一次缩放"的矩阵再叠一层,而 wx, wy 又是用旧矩阵反解出来的。两者错位,误差就累计出来了。
正确做法是:先反解出当前鼠标对应的世界点,再基于当前矩阵构造新矩阵,一次性替换,而不是累乘。也就是上面那段代码本身是对的,错的是我对它的语义理解。
不过还有第二种写法,更直观,也更适合后续扩展。它把缩放放在屏幕空间做:
python
M_new = translation(sx, sy) @ scaling(k, k) @ translation(-sx, -sy) @ M_view
这里 sx, sy 是屏幕坐标。含义是:先把世界映射到屏幕,然后在屏幕上围绕鼠标像素点缩放。因为缩放是在屏幕空间做的,鼠标点天然不动,不需要反解世界坐标。
【关键结论】两种写法数学上等价,但乘法的先后顺序绝对不能反 。M_view 在最右边表示"先做世界到屏幕的映射",在最左边表示"最后再叠加屏幕空间的变换"。顺序一换,结果就是另一回事。
我最终选了第二种,因为它不需要 np.linalg.inv,每次滚轮少一次矩阵求逆,交互更跟手,而且屏幕坐标直接来自事件,没有中间换算误差。
两种写法的对比

| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 世界空间围绕点缩放 | 语义贴近"图形学教科书",和旋转、斜切统一 | 每次要反解世界坐标,需要求逆 | 变换种类多、需要统一在世界空间描述时 |
| 屏幕空间围绕点缩放 | 无需求逆,鼠标坐标直接可用,误差小 | 语义偏"后处理",和世界空间操作混用时要小心 | 以交互为中心、缩放平移为主的查看器 |
对于我这种以浏览为主的 CAD 查看器,第二种明显更合适。
接入滚轮事件

下面是一个能跑的最小示例,环境是 Python 3.11 + PySide6 6.6 + numpy 1.26。QWidget 里维护一个 m_view,paintEvent 里用它变换所有图形点。
python
import sys
import numpy as np
from PySide6.QtCore import Qt, QPointF
from PySide6.QtGui import QPainter, QPen, QColor
from PySide6.QtWidgets import QApplication, QWidget
def identity():
return np.eye(3, dtype=np.float64)
def translation(tx, ty):
m = np.eye(3, dtype=np.float64)
m[0, 2] = tx
m[1, 2] = ty
return m
def scaling(sx, sy):
m = np.eye(3, dtype=np.float64)
m[0, 0] = sx
m[1, 1] = sy
return m
class Canvas(QWidget):
def __init__(self):
super().__init__()
self.setMouseTracking(True)
self.resize(800, 600)
# 初始视图:世界原点落在控件中心
self.m_view = translation(400, 300)
# 一条测试折线,世界坐标
self.points = [(-200, -100), (200, -100), (200, 100), (-200, 100)]
def world_to_screen(self, x, y):
p = self.m_view @ np.array([x, y, 1.0], dtype=np.float64)
return QPointF(p[0], p[1])
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.fillRect(self.rect(), QColor(30, 30, 30))
pen = QPen(QColor(220, 220, 220), 1.5)
painter.setPen(pen)
sp = [self.world_to_screen(x, y) for x, y in self.points]
for i in range(len(sp)):
painter.drawLine(sp[i], sp[(i + 1) % len(sp)])
def wheelEvent(self, event):
# 角度增量,120 对应一格
delta = event.angleDelta().y()
if delta == 0:
return
# 每格缩放 1.1 倍,方向与滚动方向一致
k = 1.1 if delta > 0 else 1 / 1.1
pos = event.position() # QPointF,控件坐标
sx, sy = pos.x(), pos.y()
# 屏幕空间围绕鼠标缩放,注意乘法顺序
m = translation(sx, sy) @ scaling(k, k) @ translation(-sx, -sy) @ self.m_view
self.m_view = m
self.update()
if __name__ == "__main__":
app = QApplication(sys.argv)
w = Canvas()
w.show()
sys.exit(app.exec())
几点说明:
event.position()返回的是QPointF,是浮点坐标。老版本 Qt 用event.pos()返回整数QPoint,PySide6 里两者都在,但推荐用position(),高 DPI 下更准。- 缩放系数我固定 1.1,不做"按距离连续缩放"。连续缩放容易出现因为浮点累积导致的抖动,除非做平滑插值,否则没必要。
- 每帧
np.linalg.inv一次都不用,滚轮响应基本没有延迟。
为什么顺序反了会出问题
我用上面的例子做了个对照实验:把乘法顺序改成 self.m_view @ translation(sx, sy) @ scaling(k, k) @ translation(-sx, -sy),其他不变。
结果很典型:缩放中心不再是鼠标,而是变成了"世界原点经过当前视图映射后的位置"。因为 M_view 在最左边,意味着右侧那一串变换是作用在屏幕空间 里的,但 sx, sy 是屏幕坐标,而缩放又是在世界空间做的,两者不在同一个空间。数学上它描述的是"先在世界里围绕屏幕坐标点缩放,再映射到屏幕",屏幕坐标被当成了世界坐标用。
这个错误不一定会崩,但视图会以一个你看不懂的点为中心缩放。我当时对着它调了半小时才发现是顺序问题,而不是坐标算错。
【踩坑提醒】判断顺序对不对,有个土办法:把鼠标放在世界原点投影到屏幕的位置上缩放。如果视图不动,说明中心选对了;如果原点在屏幕上乱跑,顺序或者坐标空间一定有一个错了。
把它做成可复用的变换栈
单个缩放写完之后,我把它抽成了一个小的变换栈,避免以后加旋转、平移时再写一遍顺序。
python
class ViewStack:
def __init__(self):
self.m = identity()
def zoom_at_screen(self, sx, sy, k):
self.m = translation(sx, sy) @ scaling(k, k) @ translation(-sx, -sy) @ self.m
def pan_screen(self, dx, dy):
self.m = translation(dx, dy) @ self.m
def reset(self):
self.m = identity()
三个方法的共同点:新的变换都乘在左边 ,因为左边代表"后执行"。平移在屏幕上加多少像素,就直接 translation(dx, dy) 乘左边;缩放围绕屏幕点,就用屏幕空间的共轭形式。这样无论后面加什么交互,只要遵守"屏幕空间变换放左边、世界空间变换放右边"的规则,顺序就不会乱。
如果以后要加旋转,比如按住右键旋转视图,它也是屏幕空间操作,同样乘左边:
python
def rotate_at_screen(self, sx, sy, theta):
c, s = np.cos(theta), np.sin(theta)
r = np.array([[c, -s, 0],
[s, c, 0],
[0, 0, 1]], dtype=np.float64)
self.m = translation(sx, sy) @ r @ translation(-sx, -sy) @ self.m
注意这里的旋转矩阵是绕 Z 轴、以屏幕点为圆心的,和缩放的共轭结构完全一致。这也是我最后选屏幕空间方案的原因:所有交互共享同一个模板,不容易写错。
数值稳定性方面的一点处理
缩放系数如果一直乘下去,m_view 的元素会指数级变大或变小。缩放到很深的时候,float64 虽然还有很大余量,但图形坐标本身可能已经超出可绘制范围。我做了一个简单限制:当缩放因子的绝对值小于 1e-6 或大于 1e6 时,直接拒绝这次缩放。
python
def zoom_at_screen(self, sx, sy, k, min_scale=1e-6, max_scale=1e6):
current = abs(self.m[0, 0])
target = current * k
if target < min_scale or target > max_scale:
return
self.m = translation(sx, sy) @ scaling(k, k) @ translation(-sx, -sy) @ self.m
这里用 m[0, 0] 近似缩放因子,前提是矩阵里没有旋转和斜切。如果以后加了旋转,这个判断要换成行列式或者显式维护一个缩放标量。这一点我没有在带旋转的场景下验证过,只是先记下这个前提。
实际效果和取舍
改完之后,滚轮缩放的手感是:鼠标放在图形任意位置滚动,那个位置的点在屏幕上基本不动,连续滚动十几下也不会漂。对比之前累乘的写法,视觉上最明显的差异是"缩放到很深时不再偏移"。
性能上,一次滚轮只做三次矩阵乘法,没有求逆,也没有三角函数,开销可以忽略。真正影响流畅度的是 paintEvent 里对每个点的变换,如果图形点很多,应该考虑在 GPU 或者用 QTransform 批量处理,这部分不在本文范围内。
还有一点值得说:QPainter 本身有 setTransform,理论上可以把矩阵直接交给它,让 Qt 做变换。但那样会丢失世界坐标到屏幕坐标的显式映射,后续做拾取(比如点选图形)时还得反解。我选择自己维护矩阵,就是为了后面做命中检测时能直接用同一份变换。
写在最后
以鼠标为中心的滚轮缩放,代码量很小,但它把"变换顺序"这件事暴露得很彻底。我现在的做法是:所有屏幕空间的交互(缩放、旋转、平移)统一乘在矩阵左边,世界空间的操作(比如图形自身的局部变换)乘在右边,并且每次基于当前矩阵构造新矩阵、一次性替换。这个规则定下来之后,后面加交互基本不用再想顺序问题。
下一步我打算把命中检测接上,用 screen_to_world 反解鼠标位置,判断点是否落在图形内部。到那一步,今天这份 m_view 就会被复用两次:一次正向画图,一次反向拾取。如果反解出来的世界坐标和正解对不上,基本可以断定是今天这套顺序又被写反了。