C++ MFC实现二维图形变换:从矩阵原理到可视化编程实践
1. 项目概述与核心价值
最近在整理过去的项目资料,翻到了一个大学时期做的课程设计,一个基于C++ MFC的二维图形变换程序。现在回头看,这个项目虽然技术栈看起来有点“复古”,但其中蕴含的图形学基础原理和Windows桌面程序的设计思想,至今依然非常有价值。尤其是在当前这个各种炫酷的WebGL和游戏引擎大行其道的时代,静下心来,从最底层的像素操作、矩阵变换开始,亲手实现一遍图形的平移、旋转、缩放,甚至是错切和复合变换,对于理解计算机如何“看见”和“描绘”世界,有着不可替代的作用。
这个程序本质上是一个图形学原理的“可视化计算器”。它的核心功能是允许用户绘制或输入基本的二维图元(比如点、线、多边形),然后通过直观的界面操作(如输入参数、点击按钮)或鼠标交互,实时地观察这些图元经过各种几何变换后的效果。它解决的问题非常直接:将教科书上抽象的变换矩阵,变成屏幕上可以拖拽、旋转、放大的生动图形。这非常适合正在学习《计算机图形学》、《VC++程序设计》课程的学生,或者任何希望深入理解图形底层原理、并想用C++在Windows平台上进行实践开发的爱好者。通过这个项目,你不仅能巩固C++和MFC的编程技能,更能打通从数学公式到屏幕像素的完整链路,这是调用现成API所无法获得的深刻体验。
2. 核心功能设计与架构思路
2.1 需求分析与功能模块拆解
接到“二维图形变换”这个题目,首先要明确边界。我们不是要做一个Photoshop,而是聚焦于最经典的几种仿射变换。因此,核心功能模块可以清晰地划分为以下几个部分:
图形绘制与数据管理模块:这是程序的基础。需要提供界面让用户创建图形(如通过鼠标点击绘制多边形,或通过对话框输入顶点坐标)。在内存中,我们需要一种数据结构来保存这些图形的原始信息。通常,我们会定义一个
CGraph基类,然后派生出CPolygon、CLine等子类。每个图形对象保存其顶点坐标的集合(std::vector<CPoint>或自定义的CPoint2D)。MFC的文档-视图架构(Document-View)在这里非常合适,CDocument派生类负责存储和管理所有的图形对象列表。变换算法核心模块:这是项目的心脏。我们需要用C++实现每一种变换的数学算法。关键点在于理解并实现齐次坐标下的变换矩阵。例如,平移变换矩阵、旋转变换矩阵、缩放变换矩阵。对于围绕任意点的旋转、缩放,需要组合“平移至原点->变换->平移回原位置”这三个步骤,这涉及到矩阵的乘法运算。我们需要封装一个
CTransform类,提供静态方法如Translate,Rotate,Scale,这些方法接受参数并返回对应的3x3变换矩阵,或者直接接受一个顶点坐标数组并对其进行原地变换。用户交互与视图呈现模块:这是MFC的强项,负责“输入”和“输出”。
- 输入:通过对话框(
CDialog)让用户输入变换参数(如平移的dx, dy,旋转角度,缩放因子)。更高级的交互可以是直接鼠标拖拽图形的控制点来实现变换,这需要结合矩阵运算和鼠标消息处理。 - 输出:在视图类(
CView的派生类,通常使用CScrollView以支持大画布)的OnDraw函数中,遍历文档中的图形列表,将每个图形的顶点坐标(无论是原始坐标还是变换后的坐标)用CDC(设备上下文)的绘图函数(如MoveTo,LineTo,Polygon)绘制出来。必须实现双缓冲技术,在内存位图中绘制完成后再一次性贴到屏幕,这样才能避免闪烁。
- 输入:通过对话框(
变换控制与历史管理模块:一个好的程序应该允许用户应用多次变换,并且能够预览、撤销。这可以通过维护两个图形列表来实现:一个保存原始图形,另一个保存当前所有变换累积后的图形。每次应用新的变换,都是基于原始图形或上一次的结果,重新计算整个变换矩阵链。实现一个简单的命令模式来管理变换历史,会大大提升程序的可用性。
注意:在MFC中,坐标系统默认以窗口客户区左上角为原点(0,0),Y轴向下为正。这与我们数学中常见的笛卡尔坐标系(原点在中心,Y轴向上)不同。在实现变换和绘制时,必须处理好这个坐标转换,否则图形会“倒过来”或位置不对。一个常见的技巧是在绘制前,对所有顶点坐标做一个“视口变换”。
2.2 技术选型:为何是C++/MFC?
今天看来,选择C++和MFC似乎有些“老派”,但对于这个特定项目,它依然有独特的优势:
- 贴近硬件与原理:C++能让你直接操作内存、精确控制计算过程,对于实现矩阵运算这类需要高效计算的底层算法非常合适。没有虚拟机和复杂运行时的开销,你能更清晰地看到每一行代码如何影响最终结果。
- Windows原生开发:MFC封装了Win32 API,提供了文档-视图、消息映射等成熟的框架,能快速搭建出带有标准菜单、工具栏、对话框的Windows桌面应用。这对于需要复杂交互的图形程序来说,比用控制台或更基础的Win32 API要高效得多。
- 教学与实践的经典组合:国内许多高校的计算机图形学课程仍以VC++ 6.0或VS + MFC作为实验环境。完成这个项目,能无缝衔接课程知识,理解GDI绘图、消息循环等Windows编程核心概念。
当然,它的缺点也很明显:MFC框架相对陈旧,现代C++特性支持不足,界面美化比较困难。但对于一个以学习和演示算法为核心的项目,这些缺点是可以接受的。我们的目标是理解原理,而非打造商业级产品。
3. 关键技术与实现细节解析
3.1 齐次坐标与变换矩阵的C++实现
这是图形变换的数学基石。在二维空间中,我们使用齐次坐标[x, y, 1]来表示一个点。这样,平移、旋转、缩放等仿射变换都可以统一用3x3矩阵乘法来表示。
首先,我们需要定义一个表示二维点的类,并重载一些运算符以便计算。
// Point2D.h class Point2D { public: double x, y, w; // w为齐次坐标分量,通常为1 Point2D(double x_ = 0, double y_ = 0, double w_ = 1) : x(x_), y(y_), w(w_) {} // 转换为笛卡尔坐标(规范化) void normalize() { if (w != 0 && w != 1) { x /= w; y /= w; w = 1; } } }; // 矩阵类,这里简化处理,使用3x3二维数组 class Matrix3x3 { public: double m[3][3]; Matrix3x3() { // 初始化为单位矩阵 for (int i = 0; i < 3; ++i) for (int j = 0; j < 3; ++j) m[i][j] = (i == j) ? 1.0 : 0.0; } // 矩阵乘法 Matrix3x3 operator*(const Matrix3x3& other) const { Matrix3x3 result; for (int i = 0; i < 3; ++i) { for (int j = 0; j < 3; ++j) { result.m[i][j] = 0; for (int k = 0; k < 3; ++k) { result.m[i][j] += m[i][k] * other.m[k][j]; } } } return result; } // 点与矩阵乘法(变换点) Point2D transformPoint(const Point2D& pt) const { Point2D result; result.x = m[0][0] * pt.x + m[0][1] * pt.y + m[0][2] * pt.w; result.y = m[1][0] * pt.x + m[1][1] * pt.y + m[1][2] * pt.w; result.w = m[2][0] * pt.x + m[2][1] * pt.y + m[2][2] * pt.w; result.normalize(); // 重要!变换后需要规范化 return result; } };接下来,实现具体的变换矩阵生成函数:
// TransformUtils.h namespace TransformUtils { // 平移矩阵 static Matrix3x3 Translate(double tx, double ty) { Matrix3x3 mat; mat.m[0][2] = tx; mat.m[1][2] = ty; return mat; } // 缩放矩阵 static Matrix3x3 Scale(double sx, double sy, double cx = 0, double cy = 0) { // 如果缩放中心不是原点(0,0),需要组合变换 if (cx != 0 || cy != 0) { return Translate(cx, cy) * Scale(sx, sy) * Translate(-cx, -cy); } Matrix3x3 mat; mat.m[0][0] = sx; mat.m[1][1] = sy; return mat; } // 旋转矩阵(角度制) static Matrix3x3 Rotate(double angle, double cx = 0, double cy = 0) { double rad = angle * 3.141592653589793 / 180.0; // 转弧度 double cosA = cos(rad); double sinA = sin(rad); Matrix3x3 rotateMat; rotateMat.m[0][0] = cosA; rotateMat.m[0][1] = -sinA; rotateMat.m[1][0] = sinA; rotateMat.m[1][1] = cosA; // 围绕任意点旋转 if (cx != 0 || cy != 0) { return Translate(cx, cy) * rotateMat * Translate(-cx, -cy); } return rotateMat; } // 错切矩阵 static Matrix3x3 Shear(double shx, double shy) { Matrix3x3 mat; mat.m[0][1] = shx; mat.m[1][0] = shy; return mat; } }实操心得:矩阵乘法的顺序至关重要!在图形学中,变换通常是从右向左应用的。例如,
T * R * S * P表示先对点P进行缩放(S),然后旋转(R),最后平移(T)。在代码中,我们通过运算符重载实现了自然的乘法顺序,但心里一定要清楚这个组合逻辑。调试时,可以单独输出每一步变换后的矩阵和坐标,来验证顺序是否正确。
3.2 MFC绘图与双缓冲技术实战
在MFC的视图类OnDraw函数中直接绘图,当图形复杂或频繁刷新时,会出现严重的闪烁现象。双缓冲是解决这个问题的标准方案。其原理是在内存中创建一个与屏幕画布兼容的位图(CBitmap),先在这个内存位图上完成所有绘制,然后一次性将位图拷贝到屏幕。
以下是实现双缓冲的关键步骤:
// 在视图类头文件中声明 class CMyGraphView : public CView { // ... private: CDC m_memDC; // 内存设备上下文 CBitmap m_memBitmap; // 内存位图 CBitmap* m_pOldBitmap; // 保存旧位图句柄 CRect m_clientRect; // 客户区矩形 // ... }; // 在视图类实现中 void CMyGraphView::OnInitialUpdate() { CView::OnInitialUpdate(); // 获取文档指针等初始化操作 } void CMyGraphView::OnDraw(CDC* pDC) { CMyGraphDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // 1. 获取当前客户区大小 GetClientRect(&m_clientRect); // 2. 检查内存DC和位图是否需要创建或调整大小 if (m_memDC.GetSafeHdc() == NULL || m_clientRect.Width() != m_memBitmap.GetBitmapDimension().cx || m_clientRect.Height() != m_memBitmap.GetBitmapDimension().cy) { // 释放旧的 if (m_pOldBitmap) { m_memDC.SelectObject(m_pOldBitmap); } m_memDC.DeleteDC(); m_memBitmap.DeleteObject(); // 创建新的兼容DC和位图 CDC* pScreenDC = GetDC(); m_memDC.CreateCompatibleDC(pScreenDC); m_memBitmap.CreateCompatibleBitmap(pScreenDC, m_clientRect.Width(), m_clientRect.Height()); ReleaseDC(pScreenDC); m_pOldBitmap = m_memDC.SelectObject(&m_memBitmap); } // 3. 清空内存位图背景(例如填充白色) CBrush whiteBrush(RGB(255, 255, 255)); m_memDC.FillRect(&m_clientRect, &whiteBrush); // 4. === 在内存DC上绘制所有图形 === // 设置绘图模式、画笔、画刷等 CPen blackPen(PS_SOLID, 2, RGB(0, 0, 0)); CPen* pOldPen = m_memDC.SelectObject(&blackPen); CBrush nullBrush; nullBrush.CreateStockObject(NULL_BRUSH); CBrush* pOldBrush = m_memDC.SelectObject(&nullBrush); // 遍历文档中的图形列表并绘制 // 这里假设图形对象有一个Draw(CDC* pDC)方法 for (auto& graph : pDoc->GetGraphList()) { graph->Draw(&m_memDC); // 传入内存DC } // 恢复旧的GDI对象 m_memDC.SelectObject(pOldPen); m_memDC.SelectObject(pOldBrush); // 5. 将内存位图一次性拷贝到屏幕DC pDC->BitBlt(0, 0, m_clientRect.Width(), m_clientRect.Height(), &m_memDC, 0, 0, SRCCOPY); }踩坑记录:
BitBlt函数的最后一个参数是光栅操作码,SRCCOPY表示直接拷贝源到目标。如果你需要实现透明、混合等效果,需要使用其他操作码,但GDI本身对Alpha混合支持很弱,复杂效果建议考虑GDI+。另外,记得在视图类析构时,清理m_memDC和m_memBitmap等GDI对象,避免资源泄漏。
3.3 图形数据管理与文档-视图通信
MFC的文档-视图架构将数据管理和数据显示分离。文档类(CDocument派生)负责存储数据,视图类负责显示和交互。它们之间通过指针和消息进行通信。
在文档类中:
// MyGraphDoc.h class CMyGraphDoc : public CDocument { // ... public: std::vector<std::shared_ptr<CGraph>> m_graphList; // 存储图形对象 std::vector<std::shared_ptr<CGraph>> m_transformedGraphList; // 存储变换后的图形 Matrix3x3 m_currentTransform; // 当前累积变换矩阵 void AddGraph(std::shared_ptr<CGraph> graph); void ApplyTransform(const Matrix3x3& transform); void ClearAll(); // ... }; // MyGraphDoc.cpp void CMyGraphDoc::ApplyTransform(const Matrix3x3& transform) { // 更新当前变换矩阵(右乘新变换) m_currentTransform = transform * m_currentTransform; // 注意顺序! // 清空变换后列表,重新计算 m_transformedGraphList.clear(); for (const auto& graph : m_graphList) { auto transformedGraph = std::make_shared<CGraph>(*graph); // 深拷贝 transformedGraph->ApplyMatrix(m_currentTransform); // 应用变换 m_transformedGraphList.push_back(transformedGraph); } // 通知所有视图更新 UpdateAllViews(NULL); }在视图类中,通过GetDocument()获取文档指针,访问数据并绘制:
void CMyGraphView::OnDraw(CDC* pDC) { CMyGraphDoc* pDoc = GetDocument(); // ... 双缓冲设置 ... // 绘制变换后的图形列表,而不是原始列表 for (auto& graph : pDoc->m_transformedGraphList) { graph->Draw(&m_memDC); } // ... }当用户通过菜单或对话框触发一个变换时,视图类会获取参数,调用文档类的ApplyTransform方法。文档类更新数据后调用UpdateAllViews,这会触发所有关联视图的OnDraw函数重绘,从而实现界面刷新。
注意事项:
UpdateAllViews会调用视图的OnUpdate方法,默认情况下OnUpdate会使整个客户区无效,触发OnDraw。如果你只更新了部分区域,可以重写OnUpdate并传入一个提示参数和无效区域,以提高效率。但在我们这个教学性质的程序中,全量重绘简单直接。
4. 完整实现流程与核心代码剖析
4.1 项目创建与环境配置
首先,使用Visual Studio创建一个新的MFC应用程序项目。选择“基于对话框”或“单文档”架构。对于图形程序,我强烈推荐“单文档”架构,因为它天然契合文档-视图模型,更容易管理多个图形对象和实现撤销重做。
- 项目创建:打开VS,选择“创建新项目” -> 搜索“MFC应用” -> 设置项目名称(如
GraphTransform)和位置 -> 在“应用程序类型”中选择“单个文档”,取消“文档/视图架构支持”的勾选(我们后面会手动处理更清晰),但为了快速上手,可以先勾选。在“用户界面功能”中,可以选择“经典菜单”等。 - 配置属性:由于我们使用了C++标准库中的
std::vector和std::shared_ptr,需要确保项目属性中“C/C++ -> 语言 -> C++语言标准”设置为“ISO C++17 标准”或更高。同时,在“预编译头”设置中,通常使用stdafx.h。 - 添加核心类:在“解决方案资源管理器”中,右键点击项目 -> “添加” -> “类”。添加我们之前设计的
Point2D,Matrix3x3,CGraph,CPolygon等类。将数学运算相关的类(Point2D,Matrix3x3,TransformUtils)放在一个独立的头文件(如GraphicMath.h)中是个好主意。
4.2 图形绘制与交互实现
我们需要让用户能够绘制图形。一个简单的方式是通过鼠标在视图中点击来添加多边形的顶点。
在视图类中处理鼠标消息:
- 在类视图中,右键点击你的视图类 -> “属性” -> 点击“消息”图标(闪电符号)-> 找到
WM_LBUTTONDOWN,WM_MOUSEMOVE,WM_LBUTTONUP消息,添加处理函数。 - 在视图类中添加成员变量,记录绘制状态:
class CMyGraphView : public CView { // ... private: bool m_bDrawing = false; // 是否正在绘制 std::vector<CPoint> m_tempPoints; // 临时存储鼠标点击的点(屏幕坐标) CPen m_draftPen; // 绘制临时线段的画笔 // ... }; - 实现消息处理函数:
void CMyGraphView::OnLButtonDown(UINT nFlags, CPoint point) { if (!m_bDrawing) { // 开始绘制新多边形 m_bDrawing = true; m_tempPoints.clear(); m_tempPoints.push_back(point); // 第一个点 SetCapture(); // 捕获鼠标,确保在窗口外也能收到消息 } else { // 添加多边形的一个顶点 m_tempPoints.push_back(point); // 实时绘制一条从上一个点到当前点的线段(橡皮筋效果) CClientDC dc(this); CPen* pOldPen = dc.SelectObject(&m_draftPen); dc.MoveTo(m_tempPoints[m_tempPoints.size() - 2]); dc.LineTo(point); dc.SelectObject(pOldPen); } CView::OnLButtonDown(nFlags, point); } void CMyGraphView::OnLButtonUp(UINT nFlags, CPoint point) { // 通常用于结束绘制,这里我们用双击结束 CView::OnLButtonUp(nFlags, point); } void CMyGraphView::OnLButtonDblClk(UINT nFlags, CPoint point) { if (m_bDrawing && m_tempPoints.size() >= 3) { // 双击结束多边形绘制 m_bDrawing = false; ReleaseCapture(); // 释放鼠标捕获 // 将屏幕坐标转换为逻辑坐标(这里假设1:1,实际可能需要转换) // 然后创建CPolygon对象,添加到文档 CMyGraphDoc* pDoc = GetDocument(); auto poly = std::make_shared<CPolygon>(); for (auto& pt : m_tempPoints) { poly->AddPoint(Point2D(pt.x, pt.y)); } pDoc->AddGraph(poly); pDoc->SetModifiedFlag(TRUE); pDoc->UpdateAllViews(NULL); // 更新显示 m_tempPoints.clear(); Invalidate(); // 清除临时绘制痕迹 } CView::OnLButtonDblClk(nFlags, point); }
4.3 变换参数输入与执行
变换参数通常通过模式对话框输入。我们可以为每种变换创建一个对话框资源。
- 添加对话框资源:在“资源视图”中,右键点击项目 -> “添加资源” -> Dialog -> 新建。设计对话框,添加编辑框(
CEdit)用于输入参数(如平移的X、Y偏移量,旋转角度,缩放比例等)。 - 为对话框添加类:右键点击对话框 -> “添加类”,命名为
CTranslateDialog等。使用“成员变量向导”为每个编辑框控件添加对应的double类型成员变量(如m_dTx,m_dTy),并设置数据交换(DDX_Text)。 - 在菜单或工具栏中触发对话框:在资源视图中编辑菜单,添加“变换”菜单项,其下包含“平移”、“旋转”、“缩放”等子项。为每个子项添加命令处理函数(在视图类或框架类中)。
- 实现命令处理函数:
void CMyGraphView::OnTransformTranslate() { CTranslateDialog dlg; if (dlg.DoModal() == IDOK) { // 弹出对话框 CMyGraphDoc* pDoc = GetDocument(); // 获取参数,创建变换矩阵 Matrix3x3 transMat = TransformUtils::Translate(dlg.m_dTx, dlg.m_dTy); // 应用到文档 pDoc->ApplyTransform(transMat); // 文档的ApplyTransform内部会调用UpdateAllViews,视图自动重绘 } }
实现围绕任意点旋转/缩放:这需要用户指定一个中心点。可以在对话框中增加两个编辑框输入中心坐标,或者更直观地,让用户在视图上点击来选择中心点。这需要结合鼠标消息,将点击的屏幕坐标记录下来,作为变换函数的cx, cy参数传入。
4.4 复合变换与变换堆栈
单一变换很简单,但实际应用中往往是多个变换的组合。例如,先缩放50%,再旋转30度,最后平移(100, 50)。我们的程序需要支持这种连续变换。
实现思路:
- 矩阵乘法累积:如之前文档类所示,我们维护一个
m_currentTransform矩阵。每次应用新变换newTransform,执行m_currentTransform = newTransform * m_currentTransform。注意矩阵乘法的顺序决定了变换应用的顺序(从右向左)。 - 提供重置功能:添加一个“重置变换”菜单,将
m_currentTransform重置为单位矩阵,并清空m_transformedGraphList(或直接重绘原始图形)。 - 可视化变换堆栈:可以在界面一侧(如一个列表控件
CListCtrl)显示已应用的变换序列,让用户清晰看到变换历史。这需要文档类额外维护一个变换描述列表。
// 在文档类中增加 struct TransformRecord { CString description; // 如 "平移(10, 20)" Matrix3x3 matrix; }; std::vector<TransformRecord> m_transformHistory; void CMyGraphDoc::ApplyTransform(const Matrix3x3& transform, const CString& desc) { TransformRecord rec; rec.description = desc; rec.matrix = transform; m_transformHistory.push_back(rec); // ... 其余计算和更新视图的代码 ... } void CMyGraphDoc::OnResetTransform() { m_currentTransform = Matrix3x3(); // 重置为单位矩阵 m_transformHistory.clear(); m_transformedGraphList.clear(); UpdateAllViews(NULL); }5. 常见问题、调试技巧与优化建议
5.1 图形绘制闪烁或残影
- 问题:即使使用了双缓冲,快速拖动图形或频繁刷新时,仍可能有轻微闪烁或旧图形残影。
- 排查:
- 检查
OnDraw中的背景填充:确保在绘制新内容前,用背景色完全清空了内存位图。使用FillRect而不是FillSolidRect,后者有时在复杂区域填充不彻底。 - 验证BitBlt参数:确保源和目的矩形大小完全匹配,且坐标正确。
- 禁用窗口背景擦除:在视图类的
OnEraseBkgnd函数中直接返回TRUE,阻止Windows在OnPaint/OnDraw之前用背景色擦除窗口,这能有效减少闪烁。BOOL CMyGraphView::OnEraseBkgnd(CDC* pDC) { return TRUE; // 告诉系统我们已经处理了背景擦除 }
- 检查
- 优化:对于静态或变化不频繁的部分图形,可以考虑缓存到位图中,只在变化部分重绘,但这会显著增加复杂度。对于教学程序,全量双缓冲重绘已足够。
5.2 变换后图形位置或形状异常
- 问题:图形旋转后跑出屏幕外,缩放中心不对,或者形状扭曲。
- 排查步骤:
- 坐标系统确认:首先确认你的图形顶点坐标是相对于哪个坐标系。是窗口客户区坐标?还是自定义的世界坐标系?在绘制前,是否进行了正确的视口变换(将世界坐标映射到屏幕坐标)?一个常见的做法是定义逻辑坐标系,原点在客户区中心,Y轴向上。
- 矩阵乘法顺序:这是最容易出错的地方。打印出每一步的变换矩阵和关键点坐标,手动计算验证。记住,
Transform A * Transform B意味着先应用B,再应用A。 - 围绕任意点变换:检查实现是否正确。必须是:
T(cx,cy) * R(angle) * T(-cx,-cy)。顺序错了效果完全不对。 - 齐次坐标规范化:在
transformPoint函数后,是否调用了normalize()?如果w分量不为1,坐标值是不正确的。 - 浮点数精度:
double类型通常足够,但在大量迭代计算后可能会有累积误差。对于比较操作,应使用容差(如fabs(a-b) < 1e-6)。
5.3 鼠标交互选取图形不准确
- 问题:点击想选中某个图形进行变换,但总是选不中,或者选中了别的图形。
- 解决方案:这涉及到“点选”或“区域选择”算法。
- 点选(判断点是否在图形内):
- 多边形:使用射线法(Ray Casting Algorithm)。从点击点发出一条水平向右的射线,计算它与多边形各边的交点个数。奇数次则在多边形内,偶数次则在多边形外。注意处理点在边上的特殊情况。
- 线段:计算点击点到线段的最短距离,如果距离小于某个阈值(如5个像素),则认为选中。
- 实现技巧:这些判断应在逻辑坐标下进行,而不是屏幕像素坐标。你需要将鼠标点击的屏幕坐标,通过逆视口变换,转换到你的世界坐标系中,再进行几何判断。
- 性能:如果图形很多,每次点击都遍历所有图形进行精确判断会很慢。可以先用图形的包围盒(Bounding Box)进行快速粗筛,排除明显不在范围内的图形,再对剩下的少数图形进行精确判断。
- 点选(判断点是否在图形内):
5.4 程序性能与扩展性思考
- 图形数量很多时卡顿:如果用户绘制了成百上千个复杂多边形,每次变换都重新计算所有顶点并重绘,可能会感到卡顿。
- 优化计算:变换计算本身是O(N)的,N是顶点总数。对于静态背景图形,可以缓存变换后的结果,只有动态图形才实时计算。
- 优化绘制:只绘制视口内的图形(裁剪)。可以使用空间索引结构(如四叉树)来快速判断哪些图形在视口内。
- 升级绘图API:MFC的GDI绘图在大量线段时效率较低。可以考虑使用GDI+(
Graphics类),它有一些硬件加速支持,或者对于更复杂的图形程序,直接学习使用Direct2D或OpenGL。
- 扩展更多变换类型:我们的框架很容易扩展。要添加新的变换(如对称反射、错切),只需在
TransformUtils中添加新的矩阵生成函数,并在UI上增加对应的输入接口即可。 - 撤销/重做功能:这是提升用户体验的关键。实现一个简单的命令模式。每个变换操作(包括添加图形)都封装成一个
Command对象,包含Execute()和Unexecute()方法。文档类维护一个命令历史栈。撤销时,弹出栈顶命令并执行其Unexecute;重做则再执行其Execute。这需要图形对象支持深拷贝,或者命令对象存储足够的状态信息来恢复。
这个基于C++ MFC的二维图形变换程序,就像一把钥匙,帮你打开了计算机图形学底层原理的大门。从矩阵乘法的顺序陷阱,到双缓冲消除闪烁的细节,再到鼠标交互中的坐标转换,每一个坑踩过去,都是实实在在的成长。虽然MFC不再是主流,但在这个过程中培养的解决问题、调试代码、设计架构的能力,是跨平台、跨技术的。如果你能把这个程序完整地实现一遍,并且理解每一行代码背后的“为什么”,那么再去学习OpenGL、DirectX甚至WebGL,你会发现那些概念变得异常亲切和容易理解。图形学的魅力,就在于这种从数学公式到视觉呈现的、可被精确控制的魔法,而亲手实现,是掌握这种魔法的最佳途径。
