1. 项目概述与核心价值最近在整理过去的项目资料翻到了一个大学时期做的课程设计一个基于C MFC的二维图形变换程序。现在回头看这个项目虽然技术栈看起来有点“复古”但其中蕴含的图形学基础原理和Windows桌面程序的设计思想至今依然非常有价值。尤其是在当前这个各种炫酷的WebGL和游戏引擎大行其道的时代静下心来从最底层的像素操作、矩阵变换开始亲手实现一遍图形的平移、旋转、缩放甚至是错切和复合变换对于理解计算机如何“看见”和“描绘”世界有着不可替代的作用。这个程序本质上是一个图形学原理的“可视化计算器”。它的核心功能是允许用户绘制或输入基本的二维图元比如点、线、多边形然后通过直观的界面操作如输入参数、点击按钮或鼠标交互实时地观察这些图元经过各种几何变换后的效果。它解决的问题非常直接将教科书上抽象的变换矩阵变成屏幕上可以拖拽、旋转、放大的生动图形。这非常适合正在学习《计算机图形学》、《VC程序设计》课程的学生或者任何希望深入理解图形底层原理、并想用C在Windows平台上进行实践开发的爱好者。通过这个项目你不仅能巩固C和MFC的编程技能更能打通从数学公式到屏幕像素的完整链路这是调用现成API所无法获得的深刻体验。2. 核心功能设计与架构思路2.1 需求分析与功能模块拆解接到“二维图形变换”这个题目首先要明确边界。我们不是要做一个Photoshop而是聚焦于最经典的几种仿射变换。因此核心功能模块可以清晰地划分为以下几个部分图形绘制与数据管理模块这是程序的基础。需要提供界面让用户创建图形如通过鼠标点击绘制多边形或通过对话框输入顶点坐标。在内存中我们需要一种数据结构来保存这些图形的原始信息。通常我们会定义一个CGraph基类然后派生出CPolygon、CLine等子类。每个图形对象保存其顶点坐标的集合std::vectorCPoint或自定义的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::vectorstd::shared_ptrCGraph m_graphList; // 存储图形对象 std::vectorstd::shared_ptrCGraph m_transformedGraphList; // 存储变换后的图形 Matrix3x3 m_currentTransform; // 当前累积变换矩阵 void AddGraph(std::shared_ptrCGraph 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_sharedCGraph(*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 C17 标准”或更高。同时在“预编译头”设置中通常使用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::vectorCPoint 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_sharedCPolygon(); 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::vectorTransformRecord 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是顶点总数。对于静态背景图形可以缓存变换后的结果只有动态图形才实时计算。优化绘制只绘制视口内的图形裁剪。可以使用空间索引结构如四叉树来快速判断哪些图形在视口内。升级绘图APIMFC的GDI绘图在大量线段时效率较低。可以考虑使用GDIGraphics类它有一些硬件加速支持或者对于更复杂的图形程序直接学习使用Direct2D或OpenGL。扩展更多变换类型我们的框架很容易扩展。要添加新的变换如对称反射、错切只需在TransformUtils中添加新的矩阵生成函数并在UI上增加对应的输入接口即可。撤销/重做功能这是提升用户体验的关键。实现一个简单的命令模式。每个变换操作包括添加图形都封装成一个Command对象包含Execute()和Unexecute()方法。文档类维护一个命令历史栈。撤销时弹出栈顶命令并执行其Unexecute重做则再执行其Execute。这需要图形对象支持深拷贝或者命令对象存储足够的状态信息来恢复。这个基于C MFC的二维图形变换程序就像一把钥匙帮你打开了计算机图形学底层原理的大门。从矩阵乘法的顺序陷阱到双缓冲消除闪烁的细节再到鼠标交互中的坐标转换每一个坑踩过去都是实实在在的成长。虽然MFC不再是主流但在这个过程中培养的解决问题、调试代码、设计架构的能力是跨平台、跨技术的。如果你能把这个程序完整地实现一遍并且理解每一行代码背后的“为什么”那么再去学习OpenGL、DirectX甚至WebGL你会发现那些概念变得异常亲切和容易理解。图形学的魅力就在于这种从数学公式到视觉呈现的、可被精确控制的魔法而亲手实现是掌握这种魔法的最佳途径。