1. 项目概述
最近在整理一些老项目的代码,翻出来一个大学时期用VC++做的计算机图形学课程作业。这个项目虽然不大,但麻雀虽小五脏俱全,它完整地实现了从底层像素操作到经典画线、画圆算法的全过程。现在回想起来,当时为了搞明白为什么一个简单的画圆算法要分八分之一象限来算,还有那个看起来有点绕的决策参数,可没少花功夫。今天我就把这个项目的核心实现思路、代码细节以及那些年踩过的坑,系统地梳理一遍。无论你是正在学习图形学的学生,还是想用VC++的GDI接口重温一下基础算法的开发者,这篇内容应该都能给你一些直接的参考。
这个项目的核心目标很明确:不依赖任何高级图形库(如OpenGL、DirectX),纯粹利用VC++(这里特指Visual C++配合MFC框架)的Windows GDI接口,在窗口的客户区上,通过手动计算每个像素点的坐标,来实现直线和圆的绘制。我们会重点剖析两个最经典的算法:用于画线的DDA算法和Bresenham算法,以及用于画圆的中点画圆算法(它本质上是Bresenham算法在圆上的变种)。你会发现,这些几十年前的算法思想,至今在嵌入式图形、低层渲染优化中依然闪烁着智慧的光芒。
2. 环境搭建与项目创建
在开始敲代码之前,得先把舞台搭好。我们选择经典的Visual Studio 2019配合MFC(Microsoft Foundation Classes)来创建项目。为什么是MFC而不是控制台程序?因为我们需要一个窗口来作为画布,直观地看到像素点一个个被“点亮”的过程,MFC提供了现成的文档-视图架构和消息处理机制,能让我们更专注于算法本身,而不是去折腾窗口创建和消息循环。
2.1 创建MFC应用程序项目
打开Visual Studio 2019,选择“创建新项目”,在搜索框里输入“MFC”,选择“MFC应用程序”,点击下一步。给项目起个名字,比如“GraphicsPrimitives”。在“应用程序类型”中,选择“单个文档”,这样我们就有一个主框架窗口和一个与之关联的视图类。在“项目样式”里,选择“Visual Studio”风格即可,“文档模板属性”和“数据库支持”都用默认设置(无)。最后在“用户界面功能”中,确保“使用经典菜单”和“使用工具栏”是选中的,这样我们就有地方添加自己的菜单命令了。
创建完成后,解决方案资源管理器里会出现一系列文件。对我们来说,最关键的是CGraphicsPrimitivesView类,它继承自CView,负责处理视图的绘制。所有的绘图代码,最终都会写在这个类的OnDraw函数或者我们自定义的绘图函数里。
2.2 理解GDI与设备上下文
在Windows下绘图,离不开一个核心概念:设备上下文。你可以把它想象成画家的画布、调色板和画笔的集合体,它是一个包含了绘图属性(如当前画笔、画刷、颜色、字体)和绘图方法的抽象对象。在MFC中,CDC类封装了设备上下文。
当我们想在窗口上画一个点时,步骤是这样的:
- 获取当前视图的设备上下文。在
OnDraw函数中,会传入一个pDC指针,它就是我们的“画笔”。 - 设置像素点的颜色。通过
pDC->SetPixel(x, y, color)函数,在坐标(x, y)处用指定颜色画一个点。 - 我们的所有算法,最终目标就是计算出一系列连续的(x, y)坐标,然后循环调用
SetPixel,从而在视觉上形成一条线或一个圆。
这里有一个非常重要的概念:客户区坐标。窗口的客户区是指除去标题栏、菜单栏、边框之后,我们可以自由绘制的区域。它的原点(0,0)在客户区的左上角,x轴向右为正,y轴向下为正。这一点和数学上常见的坐标系不同,在实现算法时需要特别注意y坐标的处理。
注意:直接使用
SetPixel进行大量绘制效率很低,因为它每次调用都会涉及系统调用。对于这个教学项目,为了清晰展示算法过程,这是可以接受的。在实际应用中,我们会使用双缓冲技术或直接操作位图数据来提升性能。
3. 直线生成算法详解与实现
直线是构成图形世界最基本的图元。如何在离散的像素网格上,用最合适的像素点来逼近一条连续的数学直线,就是直线生成算法要解决的问题。
3.1 DDA数值微分算法
DDA算法的思想非常直观:利用直线的微分方程。假设直线的起点是(x0, y0),终点是(x1, y1)。那么直线的斜率k = (y1 - y0) / (x1 - x0)。
算法步骤:
- 计算
dx = x1 - x0,dy = y1 - y0。 - 确定步进次数
steps,取dx和dy中绝对值较大的那个。这是为了保证在像素网格上每一步至少移动一个像素,让画出的线连续。 - 计算每一步x和y的增量:
xIncrement = dx / steps,yIncrement = dy / steps。 - 设置初始点
x = x0,y = y0。 - 循环
steps次,每次将x和y分别加上xIncrement和yIncrement,并对结果进行四舍五入取整,得到当前像素坐标,然后画点。
C++实现代码:
void CGraphicsPrimitivesView::DrawLine_DDA(CDC* pDC, int x0, int y0, int x1, int y1, COLORREF color) { // 计算差值 int dx = x1 - x0; int dy = y1 - y0; // 确定步数,取绝对值大的 int steps = abs(dx) > abs(dy) ? abs(dx) : abs(dy); // 避免除零错误 if (steps == 0) { pDC->SetPixel(x0, y0, color); return; } // 计算每一步的增量 float xIncrement = static_cast<float>(dx) / steps; float yIncrement = static_cast<float>(dy) / steps; // 初始点(使用浮点数以保证精度) float x = static_cast<float>(x0); float y = static_cast<float>(y0); // 开始画点 for (int i = 0; i <= steps; ++i) { // 四舍五入取整,得到像素坐标 pDC->SetPixel(static_cast<int>(x + 0.5f), static_cast<int>(y + 0.5f), color); x += xIncrement; y += yIncrement; } }DDA算法的特点与局限:
- 优点:逻辑简单,易于理解和实现,适合斜率绝对值小于1的平缓直线。
- 缺点:每一步都需要进行浮点数加法和四舍五入操作,效率较低。在斜率绝对值大于1的陡直线上,由于步数由
dx决定,可能导致画出的点不连续(除非步数取dy的绝对值,但通用代码需要判断)。因此,它更多是作为一种教学算法。
3.2 Bresenham画线算法
Bresenham算法是图形学中里程碑式的算法,它完全消除了浮点运算和取整,只使用整数加减法和位操作,效率极高。其核心思想是通过一个决策参数,来判断下一个像素点应该选在当前位置的正右方,还是右上方。
我们以斜率在0到1之间的直线为例(即0 < k < 1)。假设当前已确定的像素点为P(xp, yp),那么下一个候选像素点有两个:正右方的E(xp+1, yp)和右上方的NE(xp+1, yp+1)。我们需要判断理想直线与x = xp+1这条垂直网格线的交点Q,更靠近E还是NE。
算法推导与决策参数:设直线方程为y = kx + b,其中k = dy/dx,且dy, dx > 0,dx > dy。 定义决策参数p:p = 2 * dy * (xp+1) - 2 * dx * yp + (2*b*dx + dx - 2*dy)为了消除浮点数,Bresenham的关键技巧是只判断p的符号。经过化简(减去一个常数项不影响符号判断),我们可以得到递推公式:
- 初始决策参数:
p0 = 2dy - dx - 如果
p < 0,说明中点M在直线下方,直线更靠近E点。选择E,且下一个决策参数为:p_next = p + 2dy - 如果
p >= 0,说明中点M在直线上方,直线更靠近NE点。选择NE,且下一个决策参数为:p_next = p + 2dy - 2dx
通用Bresenham算法实现(处理所有八分圆):在实际编码中,我们不会为每种斜率情况写一遍代码,而是通过计算dx和dy的符号和绝对值,将任意直线都转换到第一象限的0<k<1情况来处理,最后再通过对称映射回原坐标。
void CGraphicsPrimitivesView::DrawLine_Bresenham(CDC* pDC, int x0, int y0, int x1, int y1, COLORREF color) { int dx = abs(x1 - x0); int dy = abs(y1 - y0); int sx = (x0 < x1) ? 1 : -1; // x方向的步进符号 int sy = (y0 < y1) ? 1 : -1; // y方向的步进符号 int err = dx - dy; // 初始决策参数,这里是 p = dx - dy 的一种变形 while (true) { pDC->SetPixel(x0, y0, color); if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 > -dy) { // 判断是否向x方向移动(对应原算法中p<0的判断,形式不同但本质一致) err -= dy; x0 += sx; } if (e2 < dx) { // 判断是否向y方向移动(对应原算法中p>=0的判断) err += dx; y0 += sy; } } }这段代码是Bresenham算法的一个非常精炼且通用的实现。它通过一个误差项err同时控制x和y方向的移动,能够处理所有方向的直线。e2 > -dy和e2 < dx这两个条件,共同决定了下一个像素点的选择。
实操心得:很多教科书上的Bresenham算法只给出了第一象限0<k<1的推导。上面这个通用版本虽然看起来简短,但理解其如何通过
sx、sy处理方向,以及err的更新逻辑,需要对照着基本推导多画图理解。这是从“知道”到“会用”的关键一步。
4. 圆的生成算法详解与实现
圆的方程是x² + y² = R²。如何在像素网格上选择点来逼近这个圆?最经典的算法是中点画圆算法,它同样是Bresenham思想在圆上的应用。
4.1 中点画圆算法原理
算法的核心是利用圆的八分对称性。我们只需要计算出第一象限中从(0, R)到(R/√2, R/√2)这八分之一圆弧上的像素点,然后通过对称性就能得到整个圆。
考虑第一象限的八分之一圆弧。假设当前已确定的像素点为P(xp, yp),那么下一个像素点有两个候选:正右方的E(xp+1, yp)和右下方的SE(xp+1, yp-1)。我们需要判断这两个候选点的中点M(xp+1, yp-0.5),是在圆内还是圆外。
定义圆函数:F(x, y) = x² + y² - R²
- 如果
F(M) < 0,则中点M在圆内,说明圆弧更靠近E点。 - 如果
F(M) >= 0,则中点M在圆外(或圆上),说明圆弧更靠近SE点。
决策参数的推导:设当前决策参数为d(对应于F(M))。
- 若
d < 0,选中点E。下一个中点M'的坐标是(xp+2, yp-0.5)。可以推导出新的决策参数:d_new = d + (2*xp + 3) - 若
d >= 0,选中点SE。下一个中点M'的坐标是(xp+2, yp-1.5)。可以推导出新的决策参数:d_new = d + (2*xp - 2*yp + 5)
初始决策参数:从点(0, R)开始,第一个中点是(1, R-0.5),所以初始d0 = F(1, R-0.5) = 1.25 - R。为了消除浮点数,我们可以令D = 4 * d,这样初始D0 = 5 - 4*R,并且递推公式中的系数也相应乘以4。
4.2 算法实现与八分对称绘制
void CGraphicsPrimitivesView::DrawCircle_MidPoint(CDC* pDC, int centerX, int centerY, int radius, COLORREF color) { int x = 0; int y = radius; int d = 1 - radius; // 初始化决策参数 D = 1 - R (由 5/4 - R 的4倍取整而来) // 绘制初始的八个对称点 Plot8Points(pDC, centerX, centerY, x, y, color); while (x < y) { x++; if (d < 0) { // 选择E点,中点在内 d += 2 * x + 1; } else { // 选择SE点,中点在外或上 y--; d += 2 * (x - y) + 1; } // 每确定一个点,就绘制其八个对称点 Plot8Points(pDC, centerX, centerY, x, y, color); } } // 辅助函数:根据第一象限的一个点(x,y),绘制圆的八个对称点 void CGraphicsPrimitivesView::Plot8Points(CDC* pDC, int cx, int cy, int x, int y, COLORREF color) { // 确保坐标在客户区内,避免绘制到窗口外(可选,但更健壮) CRect rect; GetClientRect(&rect); auto SafeSetPixel = [&](int px, int py) { if (rect.PtInRect(CPoint(px, py))) { pDC->SetPixel(px, py, color); } }; SafeSetPixel(cx + x, cy + y); // 第一象限 SafeSetPixel(cx + y, cy + x); // 第一象限对称 SafeSetPixel(cx + y, cy - x); // 第四象限 SafeSetPixel(cx + x, cy - y); // 第四象限对称 SafeSetPixel(cx - x, cy - y); // 第三象限 SafeSetPixel(cx - y, cy - x); // 第三象限对称 SafeSetPixel(cx - y, cy + x); // 第二象限 SafeSetPixel(cx - x, cy + y); // 第二象限对称 }代码解析与注意事项:
- 决策参数初始化:代码中
d = 1 - radius是对d0 = 1.25 - R取整后的结果。因为d0的分数部分是0.25,在整数算法中,我们只关心d的符号,这个0.25的偏移不影响第一个像素的选择(d0为负时选E,为正时选SE),取整后算法依然正确。 - 递推公式:
d += 2*x + 1对应4*(2*xp+3)化简并除以4;d += 2*(x - y) + 1对应4*(2*xp - 2*yp +5)化简并除以4。注意这里的x和y是已经更新后的值。 - 循环条件:
while (x < y)确保了只计算从(0,R)到(R/√2, R/√2)这八分之一圆弧。当x == y时,到达45度角,圆弧结束。 - 对称绘制:
Plot8Points函数是算法的关键优化。计算一个点,利用圆的对称性画出八个点,极大地减少了计算量。绘制顺序涵盖了所有八个象限。
踩坑记录:在实现
Plot8Points时,最容易出错的就是八个对称点的坐标正负号。一个有效的检查方法是:先在纸上画一个圆,标出圆心(cx,cy)和第一象限的点(cx+x, cy+y),然后想象这个点关于x轴、y轴、原点以及y=x直线的对称点,就能准确写出其他七个点的坐标。另外,添加SafeSetPixel进行边界检查是个好习惯,能防止因圆心坐标或半径过大导致程序尝试在窗口外绘图,虽然GDI的SetPixel对越界坐标通常有容错,但严谨的代码能避免潜在问题。
5. 在MFC视图中的集成与交互
算法函数写好了,我们需要把它们集成到MFC的视图类中,并通过菜单或鼠标交互来触发绘制。
5.1 修改视图类OnDraw函数
首先,我们可以在CGraphicsPrimitivesView::OnDraw函数中直接调用我们的算法函数,以便在窗口重绘时能显示图形。
void CGraphicsPrimitivesView::OnDraw(CDC* pDC) { CGraphicsPrimitivesDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // 示例:在视图中心画一条线和一个圆 CRect rect; GetClientRect(&rect); int centerX = rect.Width() / 2; int centerY = rect.Height() / 2; // 画一条从左上到右下的对角线 DrawLine_Bresenham(pDC, 50, 50, centerX + 100, centerY + 100, RGB(255, 0, 0)); // 红色直线 // 画一个半径为80的圆 DrawCircle_MidPoint(pDC, centerX, centerY, 80, RGB(0, 0, 255)); // 蓝色圆 }5.2 添加菜单命令实现交互
为了让用户能动态选择绘制不同的图形,我们添加菜单项。
编辑菜单资源:在资源视图中打开
IDR_MAINFRAME菜单,在“绘图”菜单下(如果没有就新建一个)添加子菜单项,例如:ID_DRAW_LINE_DDA:使用DDA算法画线ID_DRAW_LINE_BRES:使用Bresenham算法画线ID_DRAW_CIRCLE:使用中点算法画圆
为菜单项添加消息处理:在
CGraphicsPrimitivesView类上右键,选择“类向导”。在“命令”页,选择ID_DRAW_LINE_BRES等对象ID,在“消息”框选择COMMAND,点击“添加处理程序”。这会自动生成消息映射和空的处理函数。实现消息处理函数:在这些函数中,我们需要记录用户的操作状态(例如,开始点、结束点),并触发视图重绘。一种常见的做法是:
- 在视图类中添加成员变量,如
m_nDrawMode(绘制模式)、m_ptStart(起点)、m_ptEnd(终点)。 - 在菜单命令处理函数中设置
m_nDrawMode。 - 重写
OnLButtonDown和OnLButtonUp消息处理函数。在OnLButtonDown中记录起点,在OnLButtonUp中记录终点,并调用Invalidate()强制窗口重绘。 - 在
OnDraw函数中,根据m_nDrawMode、m_ptStart、m_ptEnd的值,调用相应的算法函数进行绘制。
- 在视图类中添加成员变量,如
示例代码片段:
// GraphicsPrimitivesView.h class CGraphicsPrimitivesView : public CView { ... protected: int m_nDrawMode; // 0:无,1:DDA线,2:Bres线,3:圆 CPoint m_ptStart; CPoint m_ptEnd; ... }; // GraphicsPrimitivesView.cpp void CGraphicsPrimitivesView::OnDrawLineBres() { m_nDrawMode = 2; // 设置为Bresenham画线模式 // 可以在这里设置光标形状,提示用户开始画线 } void CGraphicsPrimitivesView::OnLButtonDown(UINT nFlags, CPoint point) { if (m_nDrawMode != 0) { m_ptStart = point; m_ptEnd = point; // 初始化终点 SetCapture(); // 捕获鼠标,确保在窗口外也能收到消息 } CView::OnLButtonDown(nFlags, point); } void CGraphicsPrimitivesView::OnMouseMove(UINT nFlags, CPoint point) { if (m_nDrawMode != 0 && GetCapture() == this) { // 实时更新终点,可以实现橡皮筋效果 m_ptEnd = point; Invalidate(FALSE); // 请求重绘,FALSE表示不擦除背景,减少闪烁 } CView::OnMouseMove(nFlags, point); } void CGraphicsPrimitivesView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_nDrawMode != 0 && GetCapture() == this) { ReleaseCapture(); // 释放鼠标捕获 m_ptEnd = point; Invalidate(); // 最终绘制 // m_nDrawMode = 0; // 可选:完成一次绘制后重置模式 } CView::OnLButtonUp(nFlags, point); } void CGraphicsPrimitivesView::OnDraw(CDC* pDC) { ... // 原有的示例绘制代码可以保留或移除 // 根据交互模式绘制 switch (m_nDrawMode) { case 1: // DDA线 DrawLine_DDA(pDC, m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y, RGB(255, 0, 0)); break; case 2: // Bresenham线 DrawLine_Bresenham(pDC, m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y, RGB(0, 255, 0)); break; case 3: // 圆 { int radius = static_cast<int>(sqrt(pow(m_ptEnd.x - m_ptStart.x, 2) + pow(m_ptEnd.y - m_ptStart.y, 2))); DrawCircle_MidPoint(pDC, m_ptStart.x, m_ptStart.y, radius, RGB(0, 0, 255)); } break; } }5.3 实现橡皮筋绘制效果
上面的OnMouseMove函数中已经预留了实现橡皮筋效果的基础。但直接调用Invalidate(FALSE)会导致整个客户区重绘,如果图形复杂会有闪烁。更高级的做法是使用异或绘图模式。
在OnMouseMove中:
- 将设备上下文设置为
R2_NOTXORPEN模式。这种模式下,用同一种颜色画两次,第一次画上图形,第二次会擦除它,恢复原状。 - 首先,用上次的起点和终点(旧图形)画一次,擦除旧的橡皮筋。
- 然后,用起点和当前鼠标位置(新图形)画一次,显示新的橡皮筋。
- 记得在绘制前后保存和恢复设备上下文的原始状态。
void CGraphicsPrimitivesView::OnMouseMove(UINT nFlags, CPoint point) { if (m_nDrawMode != 0 && GetCapture() == this) { CClientDC dc(this); // 获取当前视图的设备上下文 int nOldMode = dc.SetROP2(R2_NOTXORPEN); // 设置异或模式,并保存旧模式 // 擦除旧的橡皮筋线 switch (m_nDrawMode) { case 1: case 2: DrawLine_Bresenham(&dc, m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y, RGB(0,0,0)); // 颜色在异或模式下不重要 break; case 3: // 画圆擦除稍复杂,需要计算旧半径 break; } // 更新终点并绘制新的橡皮筋线 m_ptEnd = point; switch (m_nDrawMode) { case 1: case 2: DrawLine_Bresenham(&dc, m_ptStart.x, m_ptStart.y, m_ptEnd.x, m_ptEnd.y, RGB(0,0,0)); break; case 3: // 绘制新圆 break; } dc.SetROP2(nOldMode); // 恢复原始的绘图模式 } CView::OnMouseMove(nFlags, point); }注意事项:异或绘图非常高效且无闪烁,但它对颜色有特殊要求,且在某些复杂背景下可能效果不佳。对于画圆的橡皮筋,实现起来比画线复杂,因为需要擦除一个旧圆再画一个新圆,计算量稍大。在实际项目中,可以根据需求选择是否使用。
6. 算法对比、常见问题与优化思考
实现了基本功能后,我们可以从几个维度对比一下这些算法,并思考实际应用中可能遇到的问题。
6.1 算法性能与效果对比
我们可以编写一个简单的测试函数,在视图上绘制大量图形来直观感受差异。
void CGraphicsPrimitivesView::TestAlgorithms(CDC* pDC) { const int NUM_LINES = 1000; const int LENGTH = 200; CRect rect; GetClientRect(&rect); DWORD startTime, endTime; // 测试DDA算法 startTime = GetTickCount(); for (int i = 0; i < NUM_LINES; ++i) { int x0 = rand() % rect.Width(); int y0 = rand() % rect.Height(); DrawLine_DDA(pDC, x0, y0, x0 + LENGTH, y0 + LENGTH, RGB(255, 0, 0)); } endTime = GetTickCount(); CString strMsg; strMsg.Format(_T("DDA绘制 %d 条线用时:%d ms"), NUM_LINES, endTime - startTime); // 可以用TRACE输出或绘制到屏幕上 // 测试Bresenham算法 startTime = GetTickCount(); for (int i = 0; i < NUM_LINES; ++i) { int x0 = rand() % rect.Width(); int y0 = rand() % rect.Height(); DrawLine_Bresenham(pDC, x0, y0, x0 + LENGTH, y0 + LENGTH, RGB(0, 255, 0)); } endTime = GetTickCount(); strMsg.Format(_T("Bresenham绘制 %d 条线用时:%d ms"), NUM_LINES, endTime - startTime); // 测试中点画圆算法 startTime = GetTickCount(); for (int i = 0; i < NUM_LINES/10; ++i) { // 圆计算量更大,少画一些 int cx = rand() % rect.Width(); int cy = rand() % rect.Height(); DrawCircle_MidPoint(pDC, cx, cy, 50, RGB(0, 0, 255)); } endTime = GetTickCount(); strMsg.Format(_T("中点画圆绘制 %d 个圆用时:%d ms"), NUM_LINES/10, endTime - startTime); }对比结论:
- 速度:Bresenham画线算法远快于DDA算法,因为它只有整数加减和位运算。中点画圆算法也很快。
- 精度:两者在视觉上几乎没有区别,都能生成光滑的直线/圆。Bresenham算法由于是纯整数运算,没有浮点误差累积问题。
- 适用范围:DDA算法在斜率绝对值很大(接近垂直)时,由于步长由x决定,y方向增量很大,可能导致点不连续。Bresenham算法是通用的。中点画圆算法是画圆的标准算法。
6.2 常见问题与排查
画出的线不连续、有断点
- 原因:最常见的原因是坐标计算错误,尤其是在处理不同斜率象限时,方向步进
sx,sy设置错误,或者决策参数更新逻辑写反了。 - 排查:用最简单的水平线
(0,0)->(10,0)和垂直线(0,0)->(0,10)测试。如果水平线正常,垂直线画不出来或只有起点,说明算法没有正确处理dx为0或dy为0的情况。检查循环条件和步进逻辑。
- 原因:最常见的原因是坐标计算错误,尤其是在处理不同斜率象限时,方向步进
圆看起来像椭圆,或者不对称
- 原因:像素在屏幕上的宽高比通常不是1:1。在大多数显示设备上,像素是矩形而不是正方形。我们的算法假设坐标系是均匀的。
- 解决:如果需要绘制正圆,需要根据设备上下文获取逻辑坐标到设备坐标的映射比例,并在计算y坐标时进行补偿。更简单的方法是使用GDI的
Ellipse函数,它会自动处理比例。我们这个项目旨在理解算法原理,所以通常忽略宽高比。
绘制大量图形时窗口闪烁严重
- 原因:直接在
OnDraw中频繁调用SetPixel,每次绘制都立即更新屏幕,且OnDraw被调用时会先用背景色擦除整个客户区。 - 解决:使用双缓冲技术。原理是先在内存中创建一个与屏幕画布兼容的位图(
CBitmap),获取其设备上下文(CDC),然后所有的绘图操作都先在这个内存DC上进行。绘制完成后,一次性将这个内存位图拷贝到屏幕DC上。这能有效消除闪烁。 - 代码片段:
void CGraphicsPrimitivesView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(&rect); // 创建内存DC和兼容位图 CDC memDC; CBitmap memBitmap; memDC.CreateCompatibleDC(pDC); memBitmap.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBitmap = memDC.SelectObject(&memBitmap); // 用背景色填充内存位图(模拟擦除背景) memDC.FillSolidRect(&rect, RGB(255, 255, 255)); // 白色背景 // 在内存DC上调用你的绘图算法 DrawLine_Bresenham(&memDC, ...); DrawCircle_MidPoint(&memDC, ...); // 将内存位图一次性拷贝到屏幕DC pDC->BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); // 清理资源 memDC.SelectObject(pOldBitmap); memBitmap.DeleteObject(); memDC.DeleteDC(); }
- 原因:直接在
菜单点击后,之前画的图形消失了
- 原因:MFC的文档-视图架构中,视图的
OnDraw函数在需要重绘时(如窗口被覆盖后恢复、调整大小)会被调用。如果你只在菜单命令处理函数中画图,而没有将图形数据保存下来,那么重绘时OnDraw函数就不知道要画什么。 - 解决:正确的做法是将图形的数据(如起点、终点、类型、颜色)保存在文档类(
CDocument派生类)的成员变量中(如一个CArray或std::vector)。在菜单命令或鼠标事件中,将新的图形数据添加到文档的集合中,然后调用UpdateAllViews(NULL)通知所有视图更新。在视图的OnDraw函数中,从文档获取所有图形数据并循环绘制。这才是MFC的标准数据流。
- 原因:MFC的文档-视图架构中,视图的
6.3 扩展与优化思考
抗锯齿:上述算法生成的都是“锯齿状”的图形。工业级的图形库会使用抗锯齿技术,如Wu反走样算法。它的基本思想是在直线经过的像素及其邻近像素上,根据覆盖面积设置不同的灰度值,使边缘看起来更平滑。你可以尝试实现一个简单的Wu算法作为挑战。
绘制其他图元:掌握了直线和圆,可以举一反三实现椭圆(中点椭圆算法)、矩形、多边形填充(扫描线填充算法、边界填充算法)等。
算法泛化:Bresenham算法思想可以推广到绘制其他二次曲线,甚至三次曲线。核心都是利用决策参数和递推关系,避免乘法和浮点运算。
性能极限:即使使用Bresenham算法,每个像素调用一次
SetPixel也是瓶颈。在需要极高性能的场景(如软件渲染器),通常会直接操作帧缓冲区的内存数据。在Windows下,可以通过CreateDIBSection获取指向位图数据的指针,直接修改内存,最后用BitBlt显示,这比调用数万次SetPixel要快几个数量级。
这个基于VC++的图形学算法实现项目,虽然界面简陋,但它触及了计算机图形学最基础也是最核心的部分:如何用离散的像素去表现连续的几何图形。理解这些算法,不仅是为了完成作业,更是为了在日后遇到更复杂的图形问题时,能够洞察其底层原理,知道性能瓶颈可能在哪里,以及优化的方向在哪里。当你以后使用OpenGL或DirectX绘制一个光滑的3D模型时,或许会想起,这一切绚丽的起点,不过是在一个像素一个像素地计算如何画出一条完美的直线。