news 2026/10/30 3:07:58

C++图形学课设实战:MFC绘图工具与光栅化算法实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++图形学课设实战:MFC绘图工具与光栅化算法实现

简介:这是一套面向计算机图形学初学者与 C++/MFC 开发者的二维绘图工具完整源代码,围绕 Windows 单文档工程演示几何图形绘制、编辑与保存等基础能力。压缩包共 39 个文件,以 7 个 .h 头文件、6 个 .cpp 源文件为核心,配合 obj、pch 等编译中间文件,以及 vcproj/sln/dsp 工程配置、rc/res 资源脚本、ico/bmp 界面素材和可执行程序,整体约 1.94MB,目录保留了较完整的 VC 工程结构。已有约 700 人学习下载,适合对照理解 MFC 消息驱动机制。代码覆盖自定义 CView 视图、鼠标与键盘消息映射、CDC 画线/填充/多边形、CBrush/CPen 颜色线型设置、CFont 文本输出、CFile 文件读写,以及状态栏菜单、内存 DC 双缓冲和异常处理等知识点,可作为课程设计、图形学实验或 MFC 入门项目的参考底稿。

1. 从一份“灰扑扑”的窗口说起

图形学课设里最容易被低估的,就是一套完整的 C++ 绘图工具 MFC 源代码。打开它,界面上是个灰扑扑的 Windows 窗口,左边一排工具栏,能画直线、画圆、画椭圆,能填色,也能拉个框把超出边界的线裁掉。看着像上个世纪的产物,但你真要从零写一遍 CDC 光栅化、扫描线填充、橡皮筋交互,一周时间基本就交代进去了。

这份工程真正的价值不在界面,而在于它把课堂上那些 DDA、中点画圆、种子填充、Cohen-Sutherland 编码裁剪,全部落成了能编译、能点击、能改参数的 C++ 类。它适合两类人:一类是刚学完图形学、手里压着课设要交的同学,拿到就能跑,改两个参数就能看清算法每一步的效果;另一类是想回头摸一摸 GDI 绘图底层的开发者,MFC 的设备上下文是理解 Windows 渲染模型最直接的一扇门。

2. 工程骨架:单文档程序怎么装下一个绘图工具

2.1 目录结构与模块划分

MFC 单文档(SDI)工程天生就带了 App、MainFrm、Doc、View 四个类,刚上手容易懵:到底该把“画一条线”写在哪?这套工程的分法很干脆——Doc 管数据,View 管显示和鼠标,算法单独抽到 Algo 目录,图元各自成类。目录大致是这样铺开的:

Painter2D/ ├── Painter2D.sln ├── Painter2D/ │ ├── Painter2DApp.cpp/.h // 应用入口,注册文档模板 │ ├── MainFrm.cpp/.h // 主框架窗口,工具栏、状态栏 │ ├── Painter2DDoc.cpp/.h // 图元容器:增删改查 + 序列化 │ ├── Painter2DDocView.cpp/.h // 绘制、鼠标交互、双缓冲 │ ├── Shape.h // 图元抽象基类 │ ├── LineShape.cpp/.h // 直线图元 │ ├── CircleShape.cpp/.h // 圆图元 │ ├── EllipseShape.cpp/.h // 椭圆图元 │ ├── PolygonShape.cpp/.h // 多边形(带填充) │ ├── Algo/ │ │ ├── RasterLine.cpp/.h // DDA、中点画线 │ │ ├── RasterCircle.cpp/.h // 中点画圆、中点椭圆 │ │ ├── Fill.cpp/.h // 扫描线填充、种子填充 │ │ └── Clip.cpp/.h // Cohen-Sutherland 编码裁剪 │ └── res/ // 图标、工具栏资源

分层的边界很清晰:Algo 里的函数只依赖CDC*和一个坐标,不认 Doc 也不认 View,想单独拿出去做算法练习,把这几个 cpp 拷走就能编。图元类只负责“我该用什么算法画我自己”,Doc 只管存,View 只管调度。改需求的时候,这个边界能省掉大量翻代码的时间。

工程如果是老版本留下的.dsp/.dsw,新版 VS 打开会提示一次性升级,同意即可,代码本身不用动。字符集建议统一用 Unicode,MFC 里CString、_T()混用多字节字符串是新手第一周最常见的编译错误来源。

2.2 图元对象模型:一个 CShape 基类撑起整块画布

所有图元都从同一个基类派生,基类只暴露三件事:画自己、复制自己、判断某个点是不是落在自己身上。

// Shape.h —— 图元的公共契约,只做这三件事 class CShape { public: CShape(void); virtual ~CShape(void); // 纯虚:每个图元用自己的光栅化算法把自己画到 DC 上 virtual void Draw(CDC* pDC) const = 0; // 原型克隆:撤销栈里存副本,避免指针被文档改掉 virtual CShape* Clone() const = 0; // 命中测试:后面做选择、拖动、删除时用得上 virtual BOOL HitTest(CPoint pt, int nTol = 4) const = 0; // 序列化入口,MFC 的 CArchive 认这个虚函数 virtual void Serialize(CArchive& ar); COLORREF GetColor() const { return m_clr; } void SetColor(COLORREF c) { m_clr = c; } protected: COLORREF m_clr; // 线条颜色 int m_nWidth; // 线宽,单位像素 int m_nStyle; // PS_SOLID / PS_DASH / PS_DOT BOOL m_bSelected; };

Draw声明成const,是因为画图不该改变图元自身的状态;如果某个图元需要缓存(比如多边形预先算好边表),就把缓存成员加mutable,别为了省事把 const 去掉。Clone返回堆上的新对象,调用方负责 delete,这一点在写撤销栈时非常关键——直接存原指针,用户一删图元,栈里全是野指针。

有了这层抽象,文档里就只需要维护一个CList<CShape*, CShape*>,遍历时挨个调Draw,完全不用关心下一个是什么类型。新增图元只要继承 CShape 并注册到工具栏,其余代码一行不改。

2.3 渲染管线:双缓冲把闪烁压下去

View 的OnDraw是整个程序的重绘入口,也是最容易写出闪烁的地方。默认写法是直接往屏幕 DC 上画,每次重绘 MFC 会先擦背景再画图元,鼠标一动就是一片白闪。工程里的做法是标准的双缓冲:内存 DC 画完,一次BitBlt贴到屏幕。

void CPainter2DDocView::OnDraw(CDC* pDC) { CPainter2DDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); CRect rcClient; GetClientRect(&rcClient); if (rcClient.IsRectEmpty()) return; // 1) 内存 DC + 兼容位图,尺寸与客户区严格一致 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 2) 先铺底色,否则上一帧的残影会留在位图里 memDC.FillSolidRect(&rcClient, RGB(255, 255, 255)); // 3) 所有图元画到内存 DC,写法和画到屏幕完全一样 POSITION pos = pDoc->m_shapeList.GetHeadPosition(); while (pos) { CShape* pShape = pDoc->m_shapeList.GetNext(pos); if (pShape) pShape->Draw(&memDC); } // 4) 一次性贴屏,肉眼基本看不到中间过程 pDC->BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); // 位图必须先摘出来再析构 }

几个参数值得交代清楚。CreateCompatibleBitmap用的是屏幕 DC 的格式,不要拿内存 DC 去创建,否则得到的是单色位图,画出来全是黑白。FillSolidRect这一步不能省,兼容位图刚创建时内容是不确定的,不铺底会看到花屏。最后的SelectObject(pOldBmp)是把位图从 DC 里摘出来,漏了这行,CBitmap析构时会因为仍被 DC 选中而泄漏 GDI 句柄。

配套还要把背景擦除关掉,否则系统在OnDraw之前还会再刷一次:

BOOL CPainter2DDocView::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景已经在双缓冲里刷过,这里不要再擦 }

2.4 鼠标三段式:从按下到落笔的完整链路

绘图工具的手感几乎全压在这三个消息上。工程里统一用“按下记起点、移动出预览、抬起定型”的套路,配合SetCapture保证鼠标拖出窗口也能收到移动消息。

void CPainter2DDocView::OnLButtonDown(UINT nFlags, CPoint point) { m_bDragging = TRUE; m_ptStart = m_ptCur = point; SetCapture(); // 鼠标移出客户区也照样收到 WM_MOUSEMOVE CView::OnLButtonDown(nFlags, point); } void CPainter2DDocView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDragging) { m_ptCur = point; Invalidate(FALSE); // FALSE:不擦背景,只重画,橡皮筋靠整帧重绘实现 } // 状态栏实时显示坐标,调算法时肉眼就能看出偏移 CString s; s.Format(_T("x=%d, y=%d"), point.x, point.y); ((CMainFrame*)AfxGetMainWnd())->SetStatusText(s); CView::OnMouseMove(nFlags, point); } void CPainter2DDocView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDragging) { m_bDragging = FALSE; ReleaseCapture(); CShape* pShape = CreateShapeByTool(m_nTool, m_ptStart, point); if (pShape) { GetDocument()->AddShape(pShape); GetDocument()->SetModifiedFlag(); // 标题栏出现星号,提示未保存 } Invalidate(FALSE); } CView::OnLButtonUp(nFlags, point); }

CreateShapeByTool是个简单工厂,按当前工具栏选中的工具返回对应的图元对象,起点终点直接传进去。橡皮筋预览没有用 GDI 的SetROP2(R2_NOTXORPEN),而是走整帧重绘:图元数量在课设规模下不过几十个,双缓冲重绘一次的开销远比异或笔的坐标误差好处理。等图元上百个再考虑异或或者只重绘脏矩形。

3. 光栅化核心:从 DDA 到中点画圆的像素级实现

3.1 直线:DDA 与中点画线的取舍

直线是最好理解、也最容易写错的图元。DDA 的思路是把主轴步数定下来,副轴按斜率浮点累加,代码短,但每步一次除法,且浮点累加会带来累积误差。

// Algo/RasterLine.cpp // DDA:以主轴步数为循环次数,副轴浮点步进 void DDA_Line(CDC* pDC, int x0, int y0, int x1, int y1, COLORREF clr) { int dx = x1 - x0; int dy = y1 - y0; int steps = max(abs(dx), abs(dy)); // 取主轴,保证步数足够走完全程 if (steps == 0) { pDC->SetPixelV(x0, y0, clr); return; } double xInc = (double)dx / steps; double yInc = (double)dy / steps; double x = x0, y = y0; for (int i = 0; i <= steps; ++i) { // +0.5 再截断等价于四舍五入,否则整条线会系统性偏左 pDC->SetPixelV((int)(x + 0.5), (int)(y + 0.5), clr); x += xInc; y += yInc; } }

steps取max(|dx|,|dy|)是关键,取错了会在陡峭区域出现断点。这里的+0.5不是可有可无的修饰,去掉之后负半轴方向会明显偏移一个像素,画正方形时四条边接不上角。

中点画线用整数误差项替代浮点,判断逻辑等价于“下一个像素取正右方还是右上方”。

// 中点画线(Bresenham 通用整数形式),任意斜率可用 void MidLine(CDC* pDC, int x0, int y0, int x1, int y1, COLORREF clr) { int dx = abs(x1 - x0), dy = abs(y1 - y0); int sx = (x1 > x0) ? 1 : -1; int sy = (y1 > y0) ? 1 : -1; int err = dx - dy; // 决策参数,等价于中点误差项的两倍 for (;;) { pDC->SetPixelV(x0, y0, clr); if (x0 == x1 && y0 == y1) break; int e2 = err << 1; // 左移一位代替乘 2,避免溢出用 int 就够 if (e2 > -dy) { err -= dy; x0 += sx; } // 主轴前进 if (e2 < dx) { err += dx; y0 += sy; } // 副轴按需前进 } }

两个if都用><而不是>=<=,在斜率恰好为 1 或 -1 时决定了像素落在哪一侧。工程里默认用中点画线,DDA 保留在算法菜单里做对比演示——教学场景下能直观看到两者像素排列的差异。

3.2 圆:中点算法与八分对称

圆用八分对称,一次计算落八个点,工作量直接砍到八分之一。

// 八分对称落笔:一次算点,八次 SetPixel static void Plot8(CDC* pDC, int cx, int cy, int x, int y, COLORREF clr) { pDC->SetPixelV(cx + x, cy + y, clr); pDC->SetPixelV(cx - x, cy + y, clr); pDC->SetPixelV(cx + x, cy - y, clr); pDC->SetPixelV(cx - x, cy - y, clr); pDC->SetPixelV(cx + y, cy + x, clr); pDC->SetPixelV(cx - y, cy + x, clr); pDC->SetPixelV(cx + y, cy - x, clr); pDC->SetPixelV(cx - y, cy - x, clr); } // 中点画圆:决策量 d 全程整数加减,无浮点无开方 void MidCircle(CDC* pDC, int cx, int cy, int r, COLORREF clr) { int x = 0, y = r; int d = 1 - r; // 初始判别式,对应第二个八分点 Plot8(pDC, cx, cy, x, y, clr); while (x < y) // 走到 45 度就停,剩下的靠对称 { if (d < 0) // 中点在圆内,取正上方像素 { d += 2 * x + 3; } else // 中点在圆外,取右上方像素 { d += 2 * (x - y) + 5; --y; } ++x; Plot8(pDC, cx, cy, x, y, clr); } }

判别式的两个增量2x+3和2(x-y)+5是推导结果,直接照抄即可,但要知道它们分别对应“y 不变”和“y 减一”两种情况。循环条件x < y而不是x <= y,在 45 度处会少画一个点,实际看不出来;但如果你想接上半圆与半径线,改成<=更保险。

3.3 椭圆:两段式中点算法

椭圆的曲率在四分之一弧上是变化的,得按斜率 -1 分成上下两段,分别换主轴。

static void Plot4(CDC* pDC, int cx, int cy, int x, int y, COLORREF clr) { pDC->SetPixelV(cx + x, cy + y, clr); pDC->SetPixelV(cx - x, cy + y, clr); pDC->SetPixelV(cx + x, cy - y, clr); pDC->SetPixelV(cx - x, cy - y, clr); } void MidEllipse(CDC* pDC, int cx, int cy, int a, int b, COLORREF clr) { int x = 0, y = b; // 上段:|斜率| < 1,x 每步 +1,判别式悬在 (x+1, y-0.5) double d1 = (double)b * b - (double)a * a * b + 0.25 * a * a; Plot4(pDC, cx, cy, x, y, clr); while ((double)b * b * x < (double)a * a * y) { if (d1 < 0) { d1 += (double)b * b * (2 * x + 3); } else { d1 += (double)b * b * (2 * x + 3) + (double)a * a * (-2 * y + 2); --y; } ++x; Plot4(pDC, cx, cy, x, y, clr); } // 下段:|斜率| > 1,y 每步 -1,判别式换一种写法 double d2 = (double)b * b * (x + 0.5) * (x + 0.5) + (double)a * a * (y - 1) * (y - 1) - (double)a * a * (double)b * b; while (y > 0) { if (d2 > 0) { d2 += (double)a * a * (-2 * y + 3); } else { d2 += (double)a * a * (-2 * y + 3) + (double)b * b * (2 * x + 2); ++x; } --y; Plot4(pDC, cx, cy, x, y, clr); } }

这里的d1、d2用了 double,因为a²b²在长半轴超过百像素时轻轻松松突破 int 范围——这是血泪经验,画大椭圆时线条突然断成两截,八成就是这里溢出了。要求纯整数实现的话,把判别式整体除以 4 再改写增量,代价是代码可读性下降。

3.4 像素级精度:几个必须交代的参数

光栅化阶段所有的坐标都是整型设备像素,SetPixelV比SetPixel快,因为它不返回原先的颜色值,画大图元时差别明显。但SetPixelV的坐标必须在裁剪区内,超出客户区的点会被直接丢弃,不会报错——如果你的图元画到屏幕外就消失,先检查是不是漏了视口原点。

线宽大于 1 时,逐像素算法就不适用了,得换成“画一条粗线等于画一个旋转矩形再加填充”的思路,或者干脆交给 GDI 的MoveTo/LineTo配CreatePen。工程里的做法是线宽等于 1 走自研算法,大于 1 走 GDI 画笔,界面上看不出来接缝。

4. 填充与裁剪:扫描线、种子填充和编码裁剪

4.1 扫描线填充:活性边表与顶点规则

扫描线填充是图形学课设里最容易“跑出花”的算法。核心就三步:求交、排序、配对填充。难点全在顶点和边界的处理上。

// Algo/Fill.cpp —— 逐条扫描线求交,排序后两两配对填充 static int CmpDouble(const void* a, const void* b) { double d = *(const double*)a - *(const double*)b; return (d > 0) - (d < 0); } void ScanlineFill(CDC* pDC, const CPoint* pts, int n, COLORREF clr) { if (n < 3) return; // 1) 先算包围盒,扫描线只需要在这个 y 区间里跑 int yMin = pts[0].y, yMax = pts[0].y; for (int i = 1; i < n; ++i) { yMin = min(yMin, pts[i].y); yMax = max(yMax, pts[i].y); } CArray<double, double&> xs; for (int y = yMin; y <= yMax; ++y) { xs.RemoveAll(); // 2) 每条边与扫描线求交,用半开区间 [yTop, yBot) 消掉顶点重复计数 for (int i = 0; i < n; ++i) { const CPoint& p = pts[i]; const CPoint& q = pts[(i + 1) % n]; if (p.y == q.y) continue; // 水平边不参与求交 int yTop = min(p.y, q.y), yBot = max(p.y, q.y); if (y < yTop || y >= yBot) continue; // 上闭下开,顶点只算一次 double t = (double)(y - p.y) / (q.y - p.y); xs.Add(p.x + t * (q.x - p.x)); } if (xs.GetSize() < 2) continue; // 3) 交点排序后两两配对,中间那段就是多边形内部 qsort(xs.GetData(), xs.GetSize(), sizeof(double), CmpDouble); for (int k = 0; k + 1 < xs.GetSize(); k += 2) { int x1 = (int)ceil(xs[k] - 0.5); // 左边界向外取整 int x2 = (int)floor(xs[k + 1] + 0.5); // 右边界向内取整 for (int x = x1; x <= x2; ++x) pDC->SetPixelV(x, y, clr); } } }

半开区间[yTop, yBot)是这段代码的灵魂。如果写成闭区间,扫描线正好穿过顶点时会算出一个重复交点,排序后配对错位,多边形的尖角处就会漏填一块或者溢出一条线出去。同理,水平边必须先跳过,它对填充结果没有任何贡献,参与求交只会让交点数量变成奇数。

4.2 种子填充:四连通与栈的实现

种子填充适合处理不规则区域和带内孔的多边形,思路是从一个内部像素出发,把颜色相同的连通像素一层层扩散。

// 四连通种子填充:用显式栈代替递归,避免大面积填充打穿调用栈 void SeedFill(CDC* pDC, int x, int y, COLORREF fillClr, COLORREF bndClr) { CArray<CPoint, CPoint&> stack; stack.Add(CPoint(x, y)); while (!stack.IsEmpty()) { CPoint p = stack[stack.GetSize() - 1]; stack.RemoveAt(stack.GetSize() - 1); COLORREF c = pDC->GetPixel(p); if (c == fillClr || c == bndClr) continue; // 已填或者碰到边界,跳过 pDC->SetPixelV(p, fillClr); // 四连通:上下左右,八个方向会漏过对角细缝 stack.Add(CPoint(p.x + 1, p.y)); stack.Add(CPoint(p.x - 1, p.y)); stack.Add(CPoint(p.x, p.y + 1)); stack.Add(CPoint(p.x, p.y - 1)); } }

GetPixel拿到的是设备像素的 COLORREF,跟SetPixelV写入的值必须严格相等才能判等,这一点在 32 位色深下没问题,但如果你在调色板模式下跑,颜色会被映射成最近的索引色,判等永远不成立,填充会一直扩散到屏幕边缘。要么固定用 24/32 位色深,要么额外维护一张与屏幕同尺寸的标记位图。

四连通和八连通的差别在细缝上:边界只有一个像素宽时,四连通填不满对角相连的区域,八连通会从对角漏出去把整个窗口刷掉。工程里默认四连通,菜单里留了八连通做对比。

4.3 Cohen-Sutherland 编码裁剪

裁剪要解决的是“线段一部分在窗口内、一部分在窗口外”的情况。区域编码把窗口四周分成四个位,两端点的编码一算,三种情况立刻分得清清楚楚。

// Algo/Clip.cpp —— 四位区域码 #define CLIP_TOP 0x1 #define CLIP_BOTTOM 0x2 #define CLIP_RIGHT 0x4 #define CLIP_LEFT 0x8 static int Encode(CPoint p, const CRect& rc) { int code = 0; if (p.y < rc.top) code |= CLIP_TOP; if (p.y > rc.bottom) code |= CLIP_BOTTOM; if (p.x > rc.right) code |= CLIP_RIGHT; if (p.x < rc.left) code |= CLIP_LEFT; return code; } BOOL ClipLine(CPoint& p0, CPoint& p1, const CRect& rc) { int c0 = Encode(p0, rc), c1 = Encode(p1, rc); for (int guard = 0; guard < 32; ++guard) // 防御:理论上必然收敛,给个上限兜底 { if ((c0 | c1) == 0) return TRUE; // 两端都在窗口内,整条保留 if ((c0 & c1) != 0) return FALSE; // 两端同侧越界,整条丢掉 int c = c0 ? c0 : c1; // 挑一个在外面的端点来裁 CPoint& p = c0 ? p0 : p1; CPoint q = c0 ? p1 : p0; double x = p.x, y = p.y; if (c & CLIP_TOP) { y = rc.top; x = p.x + (double)(rc.top - p.y) * (q.x - p.x) / (q.y - p.y); } else if (c & CLIP_BOTTOM) { y = rc.bottom; x = p.x + (double)(rc.bottom - p.y) * (q.x - p.x) / (q.y - p.y); } else if (c & CLIP_RIGHT) { x = rc.right; y = p.y + (double)(rc.right - p.x) * (q.y - p.y) / (q.x - p.x); } else { x = rc.left; y = p.y + (double)(rc.left - p.x) * (q.y - p.y) / (q.x - p.x); } p = CPoint((int)(x + 0.5), (int)(y + 0.5)); c0 = Encode(p0, rc); // 两个端点都重新编码,省得漏改 c1 = Encode(p1, rc); } return FALSE; }

(c0 | c1) == 0和(c0 & c1) != 0是位运算的经典用法,前者判“完全在内部”,后者判“完全在同一侧外部”。剩下就是“部分重叠”,必须逐位裁剪。注意CPoint& p = c0 ? p0 : p1;这里必须用引用,用值拷贝的话裁完的结果回不去,循环会原地打转——这个坑我见过不少人在课程作业里踩。

4.4 填充与裁剪的配合顺序

一个多边形既要裁剪又要填充,顺序不能乱。正确做法是先用 Sutherland-Hodgman 把多边形对矩形窗口逐边裁一遍,得到新的顶点序列,再拿这个序列去扫描线填充。反过来先填充再裁剪,填充出来的是原多边形的像素集合,裁剪阶段还得重新做一次像素级与运算,效率差好几倍,而且边界像素容易漏。

工程里在 Doc 层留了个m_bClipEnabled开关,关闭时图元直接按原坐标画,打开时先走裁剪再走填充。调算法的时候把这个开关来回切,能直观看出裁剪前后的顶点变化。

5. 常见的坑与排查:像素偏移、闪烁、坐标越界

5.1 拖动时满屏白闪

现象:鼠标一按下拖动,整个客户区闪成一片白,图元像在打摆子。

原因:MFC 默认会先响应WM_ERASEBKGND用背景刷把整个窗口刷一遍,再调OnDraw重画。两次绘制之间有肉眼可见的时间差,就是闪烁。再叠加每次Invalidate()都传了TRUE(擦背景),等于把闪烁放大了一倍。

解决:OnEraseBkgnd直接return TRUE,背景交给双缓冲里面的FillSolidRect统一处理;同时所有重绘调用统一传Invalidate(FALSE)。这两处改完,闪烁基本消失,剩下的轻微抖动来自位图创建开销,把兼容位图缓存成成员变量、只在窗口尺寸变化时重建,就彻底平了。

5.2 画出来的线断点、偏移一个像素

现象:斜线在陡峭段出现断点,正方形四条边接不上角,整体看起来偏了半个像素。

原因:一是 DDA 里漏了+0.5的四舍五入,直接(int)是向零截断,负方向会系统性偏移;二是混用了MoveTo/LineTo和SetPixelV,GDI 的LineTo不画终点像素,而自研算法画了,两条线首尾相接时就多或少一个点。

解决:所有自研光栅化算法统一走(int)(v + 0.5)的取整,别用floor也别用截断;同一条线要么全用 GDI 画,要么全用自研算法画,不要混着来。另外SetPixelV的坐标系原点是客户区左上角,如果用了SetViewportOrg挪过原点,记得把鼠标坐标先减掉偏移量再传进算法。

5.3 多边形尖角漏填或多填一条

现象:填充带尖角的多边形时,某个顶点附近空了一小块,或者边界外溢出一条扫描线。

原因:扫描线正好穿过顶点,两条边各贡献一个交点,交点总数变成奇数,配对时最后一个交点没有搭档,程序要么越界要么乱填。另一种情况是水平边参与求交,同样制造出一个多余交点。

解决:严格执行“上闭下开”的区间规则加“跳过水平边”。判断顶点时只看yTop <= y < yBot,等号只落在上端点。如果顶点在扫描线上正好是局部极值,两条边一条算进一条不算,交点数量自然保持偶数。

5.4 缩放之后鼠标点不中图元

现象:视图放大一倍后,鼠标明明点在线上,HitTest却返回 FALSE;拖动图元时位置也对不上。

原因:HitTest里用的是设备坐标,而图元存储的是世界坐标。如果视图做了缩放或者平移,两套坐标之间差了变换矩阵,直接用设备坐标去比世界坐标当然对不上。

解决:在 View 层维护一个统一的映射函数,鼠标消息进来先DeviceToWorld,绘制时再WorldToDevice,所有算法内部一律用世界坐标,只有最后落笔那一刻才转设备坐标。MFC 自己的SetMapMode(MM_ANISOTROPIC)配SetWindowExt/SetViewportExt也能做,但它会悄悄影响所有 GDI 调用的坐标,跟自研的SetPixelV算法混用时极易翻车,我一般宁愿自己写两行换算也不碰它。

5.5 撤销几次后程序崩了

现象:连续撤销到某一步,弹断言或者直接闪退。

原因:撤销栈里存的是CShape*指针,而DeleteShape已经把对象 delete 掉了,栈里留的全是野指针;另一种是Clone出来的副本存进栈后从没释放,对象越攒越多,最后 GDI 句柄耗尽。

解决:撤销栈统一存Clone()出来的副本,压栈时克隆、出栈时接管所有权并负责 delete,规则写死在PushUndo/Undo两个函数里,别在业务代码里到处 new/delete。配套把 GDI 对象也管起来:CPen、CBrush、CBitmap用完必须DeleteObject,或者用 RAII 包一层,函数返回时自动归还。

6. 二次开发:加一个 Bezier 图元要动几个文件

想给这套工具加个三次 Bezier 曲线,路径其实很固定。第一步在Shape.h同级新增BezierShape.h/cpp,继承CShape,实现Draw、Clone、HitTest、Serialize四个虚函数;Draw里按 t 从 0 到 1 取 200 个采样点,逐段调MidLine连起来,这样曲线和直线用的是同一套光栅化代码,像素风格统一。

// BezierShape.cpp —— 采样法绘制三次 Bezier,复用中点画线 void CBezierShape::Draw(CDC* pDC) const { const int N = 200; // 采样密度,小于 60 会看出折线感 CPoint prev = Eval(0.0); for (int i = 1; i <= N; ++i) { CPoint cur = Eval((double)i / N); MidLine(pDC, prev.x, prev.y, cur.x, cur.y, m_clr); // 复用直线算法 prev = cur; } } CPoint CBezierShape::Eval(double t) const { double u = 1.0 - t; // 四个控制点 P0..P3,按 Bernstein 基函数展开 double x = u*u*u*m_pts[0].x + 3*u*u*t*m_pts[1].x + 3*u*t*t*m_pts[2].x + t*t*t*m_pts[3].x; double y = u*u*u*m_pts[0].y + 3*u*u*t*m_pts[1].y + 3*u*t*t*m_pts[2].y + t*t*t*m_pts[3].y; return CPoint((int)(x + 0.5), (int)(y + 0.5)); }

第二步在工具栏资源里加一个按钮,OnToolBezier里把m_nTool置成对应枚举值;第三步在CreateShapeByTool的 switch 里加一个分支,让鼠标抬起时能造出这个图元。改动范围就这三

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/30 3:05:27

小鹏X-World平台:世界模型如何重塑智能驾驶

1. 项目背景与核心概念小鹏汽车最新发布的X-World技术平台&#xff0c;标志着国产智能汽车在"世界模型"领域的重大突破。这个看似抽象的技术名词&#xff0c;实际上正在重塑我们与智能汽车的交互方式。世界模型&#xff08;World Model&#xff09;本质上是一种能够实…

作者头像 李华
网站建设 2026/10/30 3:05:25

开源AI无人机平台:效率提升50%的技术解析

1. 项目概述&#xff1a;开源AI无人机平台如何实现效率跃升去年夏天&#xff0c;我们在某大型物流园区部署了这套开源AI无人机平台后&#xff0c;原本需要6名巡检员耗时4小时完成的仓库巡检任务&#xff0c;现在只需2台无人机45分钟就能完成&#xff0c;且数据准确率提升了32%。…

作者头像 李华
网站建设 2026/10/30 3:04:14

Web前端安全:XSS与CSRF攻击防御实战指南

1. Web前端安全的核心挑战与现状作为每天与浏览器打交道的开发者&#xff0c;我们常常陷入一个误区&#xff1a;认为只要后端接口足够安全&#xff0c;前端代码就可以高枕无忧。但现实情况是&#xff0c;前端恰恰是整个Web系统中最容易被攻击的薄弱环节。根据OWASP 2023年报告&…

作者头像 李华
网站建设 2026/10/30 3:04:13

系统测试技术演进与实践指南

1. 系统测试的本质与演进背景2008年我刚入行时&#xff0c;系统测试还停留在"点按钮看结果"的阶段。当时测试团队最值钱的资产是一摞发黄的测试用例文档&#xff0c;新人入职第一课就是学习如何用Excel记录测试结果。十年后&#xff0c;当我看到自动化测试平台实时生…

作者头像 李华
网站建设 2026/10/30 3:04:12

系统测试十年演进:从手工到智能的质量保障体系

1. 系统测试的十年演进&#xff1a;从基础验证到智能保障十年前我刚入行时&#xff0c;系统测试还停留在手工执行测试用例的阶段。记得当时为了验证一个电商系统的订单流程&#xff0c;我们需要在十几个终端设备上手动操作上百次。如今回看这十年的技术演进&#xff0c;系统测试…

作者头像 李华