从零写一个CAD 05:以鼠标为中心的滚轮缩放矩阵顺序不能反

发布时间:2026/10/7 8:22:31
从零写一个CAD 05:以鼠标为中心的滚轮缩放矩阵顺序不能反
做 CAD 类工具滚轮缩放看着是最没技术含量的功能但真正写起来它往往是第一个把视图坐标系搞乱的地方。前面几篇里我已经把画布、世界坐标、变换栈搭起来了这一篇专门处理缩放。目标很明确滚轮滚动时鼠标指着的那个点在屏幕上不动视图围绕它放大或缩小。听起来一句话实际写的时候我改了三次才稳定下来问题全都出在矩阵乘法的顺序上。先把坐标系说清楚在动手之前必须先把三个坐标空间定死否则后面全是玄学。屏幕坐标screen鼠标事件给的坐标单位是像素原点在控件左上角。世界坐标world图形数据本身的坐标单位由业务决定可以是毫米也可以是任意单位。视图变换view transform从 world 到 screen 的映射用一个 3x3 矩阵表示。这里我用 3x3 齐次矩阵而不是 2x3。原因是后面如果要加旋转、斜切3x3 不用改结构只是多几个非零元素。Python 里我用numpy存矩阵形状固定(3, 3)dtypefloat64。importnumpyasnpdefidentity():returnnp.eye(3,dtypenp.float64)deftranslation(tx,ty):mnp.eye(3,dtypenp.float64)m[0,2]tx m[1,2]tyreturnmdefscaling(sx,sy):mnp.eye(3,dtypenp.float64)m[0,0]sx m[1,1]syreturnm约定点用列向量[x, y, 1]^T变换写成p_screen M_view p_world。这个约定一旦定了就不要中途改成行向量否则每一处乘法都要重新想一遍。从鼠标位置反推世界坐标要做“以鼠标为中心”第一步是把鼠标的屏幕坐标换算到世界坐标。因为p_screen M_view p_world反解就是defscreen_to_world(m_view,sx,sy):invnp.linalg.inv(m_view)pinv np.array([sx,sy,1.0],dtypenp.float64)returnp[0]/p[2],p[1]/p[2]这里除以p[2]是齐次坐标归一化。当前变换没有投影p[2]恒等于 1但保留这一步后面如果引入透视投影就不用改。拿到鼠标对应的世界点(wx, wy)之后缩放的目标就很清晰缩放前后这个点投影回屏幕位置必须还是(sx, sy)。缩放矩阵该往哪边乘这是整个过程里最容易出错的地方。我一开始的想法很朴素既然要围绕鼠标缩放那就先平移到鼠标再缩放再平移回去。写成矩阵就是M_newM_view translation(wx,wy) scaling(k,k) translation(-wx,-wy)跑起来之后发现视图确实在缩放但鼠标指的那个点会缓慢漂移。缩得越大偏得越离谱。我对着结果盯了半天才意识到问题出在“在哪个空间里做平移”上。关键点是translation(wx, wy)里的wx, wy是世界坐标所以这个平移必须作用在世界的右侧而它左边乘的M_view会把结果再映射到屏幕。这个写法本身没错但它描述的是“在世界空间里围绕世界点(wx, wy)缩放”。如果鼠标的世界坐标算得准它确实应该是对的。那为什么会漂我打印了几组数据发现问题不在矩阵而在我把M_view的更新写成了原地累积# 错误写法把新矩阵当作“增量”继续往上乘m_viewm_view translation(wx,wy) scaling(k,k) translation(-wx,-wy)这样写等于每次缩放都基于“已经包含了上一次缩放”的矩阵再叠一层而wx, wy又是用旧矩阵反解出来的。两者错位误差就累计出来了。正确做法是先反解出当前鼠标对应的世界点再基于当前矩阵构造新矩阵一次性替换而不是累乘。也就是上面那段代码本身是对的错的是我对它的语义理解。不过还有第二种写法更直观也更适合后续扩展。它把缩放放在屏幕空间做M_newtranslation(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_viewpaintEvent里用它变换所有图形点。importsysimportnumpyasnpfromPySide6.QtCoreimportQt,QPointFfromPySide6.QtGuiimportQPainter,QPen,QColorfromPySide6.QtWidgetsimportQApplication,QWidgetdefidentity():returnnp.eye(3,dtypenp.float64)deftranslation(tx,ty):mnp.eye(3,dtypenp.float64)m[0,2]tx m[1,2]tyreturnmdefscaling(sx,sy):mnp.eye(3,dtypenp.float64)m[0,0]sx m[1,1]syreturnmclassCanvas(QWidget):def__init__(self):super().__init__()self.setMouseTracking(True)self.resize(800,600)# 初始视图世界原点落在控件中心self.m_viewtranslation(400,300)# 一条测试折线世界坐标self.points[(-200,-100),(200,-100),(200,100),(-200,100)]defworld_to_screen(self,x,y):pself.m_view np.array([x,y,1.0],dtypenp.float64)returnQPointF(p[0],p[1])defpaintEvent(self,event):painterQPainter(self)painter.setRenderHint(QPainter.Antialiasing)painter.fillRect(self.rect(),QColor(30,30,30))penQPen(QColor(220,220,220),1.5)painter.setPen(pen)sp[self.world_to_screen(x,y)forx,yinself.points]foriinrange(len(sp)):painter.drawLine(sp[i],sp[(i1)%len(sp)])defwheelEvent(self,event):# 角度增量120 对应一格deltaevent.angleDelta().y()ifdelta0:return# 每格缩放 1.1 倍方向与滚动方向一致k1.1ifdelta0else1/1.1posevent.position()# QPointF控件坐标sx,sypos.x(),pos.y()# 屏幕空间围绕鼠标缩放注意乘法顺序mtranslation(sx,sy) scaling(k,k) translation(-sx,-sy) self.m_view self.m_viewm self.update()if__name____main__:appQApplication(sys.argv)wCanvas()w.show()sys.exit(app.exec())几点说明event.position()返回的是QPointF是浮点坐标。老版本 Qt 用event.pos()返回整数QPointPySide6 里两者都在但推荐用position()高 DPI 下更准。缩放系数我固定 1.1不做“按距离连续缩放”。连续缩放容易出现因为浮点累积导致的抖动除非做平滑插值否则没必要。每帧np.linalg.inv一次都不用滚轮响应基本没有延迟。为什么顺序反了会出问题我用上面的例子做了个对照实验把乘法顺序改成self.m_view translation(sx, sy) scaling(k, k) translation(-sx, -sy)其他不变。结果很典型缩放中心不再是鼠标而是变成了“世界原点经过当前视图映射后的位置”。因为M_view在最左边意味着右侧那一串变换是作用在屏幕空间里的但sx, sy是屏幕坐标而缩放又是在世界空间做的两者不在同一个空间。数学上它描述的是“先在世界里围绕屏幕坐标点缩放再映射到屏幕”屏幕坐标被当成了世界坐标用。这个错误不一定会崩但视图会以一个你看不懂的点为中心缩放。我当时对着它调了半小时才发现是顺序问题而不是坐标算错。【踩坑提醒】判断顺序对不对有个土办法把鼠标放在世界原点投影到屏幕的位置上缩放。如果视图不动说明中心选对了如果原点在屏幕上乱跑顺序或者坐标空间一定有一个错了。把它做成可复用的变换栈单个缩放写完之后我把它抽成了一个小的变换栈避免以后加旋转、平移时再写一遍顺序。classViewStack:def__init__(self):self.midentity()defzoom_at_screen(self,sx,sy,k):self.mtranslation(sx,sy) scaling(k,k) translation(-sx,-sy) self.mdefpan_screen(self,dx,dy):self.mtranslation(dx,dy) self.mdefreset(self):self.midentity()三个方法的共同点新的变换都乘在左边因为左边代表“后执行”。平移在屏幕上加多少像素就直接translation(dx, dy)乘左边缩放围绕屏幕点就用屏幕空间的共轭形式。这样无论后面加什么交互只要遵守“屏幕空间变换放左边、世界空间变换放右边”的规则顺序就不会乱。如果以后要加旋转比如按住右键旋转视图它也是屏幕空间操作同样乘左边defrotate_at_screen(self,sx,sy,theta):c,snp.cos(theta),np.sin(theta)rnp.array([[c,-s,0],[s,c,0],[0,0,1]],dtypenp.float64)self.mtranslation(sx,sy) r translation(-sx,-sy) self.m注意这里的旋转矩阵是绕 Z 轴、以屏幕点为圆心的和缩放的共轭结构完全一致。这也是我最后选屏幕空间方案的原因所有交互共享同一个模板不容易写错。数值稳定性方面的一点处理缩放系数如果一直乘下去m_view的元素会指数级变大或变小。缩放到很深的时候float64虽然还有很大余量但图形坐标本身可能已经超出可绘制范围。我做了一个简单限制当缩放因子的绝对值小于 1e-6 或大于 1e6 时直接拒绝这次缩放。defzoom_at_screen(self,sx,sy,k,min_scale1e-6,max_scale1e6):currentabs(self.m[0,0])targetcurrent*kiftargetmin_scaleortargetmax_scale:returnself.mtranslation(sx,sy) scaling(k,k) translation(-sx,-sy) self.m这里用m[0, 0]近似缩放因子前提是矩阵里没有旋转和斜切。如果以后加了旋转这个判断要换成行列式或者显式维护一个缩放标量。这一点我没有在带旋转的场景下验证过只是先记下这个前提。实际效果和取舍改完之后滚轮缩放的手感是鼠标放在图形任意位置滚动那个位置的点在屏幕上基本不动连续滚动十几下也不会漂。对比之前累乘的写法视觉上最明显的差异是“缩放到很深时不再偏移”。性能上一次滚轮只做三次矩阵乘法没有求逆也没有三角函数开销可以忽略。真正影响流畅度的是paintEvent里对每个点的变换如果图形点很多应该考虑在 GPU 或者用QTransform批量处理这部分不在本文范围内。还有一点值得说QPainter本身有setTransform理论上可以把矩阵直接交给它让 Qt 做变换。但那样会丢失世界坐标到屏幕坐标的显式映射后续做拾取比如点选图形时还得反解。我选择自己维护矩阵就是为了后面做命中检测时能直接用同一份变换。写在最后以鼠标为中心的滚轮缩放代码量很小但它把“变换顺序”这件事暴露得很彻底。我现在的做法是所有屏幕空间的交互缩放、旋转、平移统一乘在矩阵左边世界空间的操作比如图形自身的局部变换乘在右边并且每次基于当前矩阵构造新矩阵、一次性替换。这个规则定下来之后后面加交互基本不用再想顺序问题。下一步我打算把命中检测接上用screen_to_world反解鼠标位置判断点是否落在图形内部。到那一步今天这份m_view就会被复用两次一次正向画图一次反向拾取。如果反解出来的世界坐标和正解对不上基本可以断定是今天这套顺序又被写反了。