简易图形绘制系统复盘:从光栅化到交互设计
2026/8/31 23:25:57 网站建设 项目流程

简介:本资源是南京大学计算机科学与技术系图形学课程的大作业成果——一个基于Python实现的简易图形绘制系统,面向高校计算机图形学初学者及实践者,旨在通过完整项目驱动方式掌握核心图形学原理与编程实现。资源共13个文件,包含3个核心Python源码(含算法实现、命令行与GUI交互模块)、6幅BMP测试图像、1张PNG结构示意图、1份README说明文档及辅助文本文件,整体压缩包仅88KB,轻量易读。已有897人学习下载,体现其在教学实践中的广泛参考价值。读者可直接运行cg_gui.py启动图形界面,结合cg_algorithms.py深入理解Bresenham直线算法、RGB颜色操作、二维几何变换矩阵应用及基础渲染逻辑;目录结构清晰分层,涵盖输入/输出/演示/图像资源等模块,便于按图索骥开展复现、调试与二次开发。 图形学课程里,几乎每个学校都会安排一次“简易图形绘制系统”大作业。听起来很简单,无非就是鼠标点两下画条直线、画个圆,但真动手之后才会发现,这个项目把图形学最核心的一整条链路全串起来了:几何模型怎么组织、坐标怎么变换、光栅化怎么把连续几何变成离散像素、交互怎么把鼠标点击变成图元参数、拾取和裁剪又怎么处理。它不是一个“画图板”Demo,而是一个微缩版的图形系统,你把这条链路走通一遍,后面再学OpenGL、学渲染管线,会轻松非常多。

这篇文章是我在做南京大学计算机科学与技术系图形学大作业“简易图形绘制系统”时的完整复盘,包含整体设计思路、光栅化算法的实现要点、交互框架的搭建过程、常见问题的排查记录,以及一些课程文档里不会写但实测非常管用的经验。适合正在做类似图形学作业的同学参考,也适合想快速了解一个最小图形系统内部结构的读者。

1. 项目整体设计与思路拆解

1.1 核心需求演进:从“画出来”到“建系统”

很多同学拿到这类题目,第一反应是“我直接用Qt的QPainter::drawLine不就行了”,或者“OpenGL一句glDrawArrays搞定”。如果你只是在做普通的桌面绘图应用,这么写没有任何问题,但图形学大作业的真正考点是:你要在不依赖高级封装的前提下,把“几何对象”变成“屏幕像素”的过程自己实现一遍。

也就是说,这个项目的本质不是做一个画图软件,而是做一个“解释器”:用户在界面上用鼠标描述出直线、圆、椭圆、多边形、曲线的几何参数,系统把这些参数交给光栅化模块,由光栅化模块决定哪些像素被点亮。你做的是渲染管线的简化版,不是界面控件堆砌。

从课程评分角度,常见的分档大概是这样的:

  • 及格档:能用鼠标交互画出直线、圆、矩形,并且图元能显示在画布上。
  • 良好档:图元可以选中、删除、移动、改变颜色;实现了多边形填充或曲线绘制。
  • 优秀档:算法实现完整(Bresenham、中点画圆、种子填充、Bezier曲线等),支持裁剪、图形变换(平移/旋转/缩放)、保存/读取工程文件,并且界面交互顺畅。

所以你在动手之前,最好先确认自己的目标分数段。我的建议是:先保证算法层完全独立,再考虑界面层。因为算法层是课程的核心,界面层只是外壳,算法写得扎实,哪怕界面粗糙一点,分数也不会低。

1.2 技术选型:为什么我用了 C++ + Qt + 手写光栅化

技术栈选择上,我最终敲定的是 C++ + Qt 5,绘制部分不用 QPainter 的现成图元函数,而是自己实现所有光栅化算法,然后把像素数据交给 QImage 显示。

为什么这么选?

  • C++ 是图形学课程的默认语言,内存管理和对象组织方式更贴近底层实现,写出来的代码能直接对照教材伪代码。
  • Qt 的消息循环和事件系统非常成熟,处理鼠标交互比 Win32 API 省心太多,跨平台也不用操心。
  • 不直接用 OpenGL,是因为 OpenGL 本身已经把光栅化隐藏到硬件里了,你在这里能获得的知识量不够;但你可以把 QImage 理解成一个“内存帧缓冲”,这跟 OpenGL 的 framebuffer 概念是相通的,后面对接硬件渲染并不会白学。

要强调的是:QPainter 可以用来做最终显示和 UI 绘制,但不应拿它来画业务图元。你可以把 QImage 当作画布,自己写的 drawLine 往这个 image 上 setPixel,也可以把 QImage 直接显示在 QLabel 上。这样既保证了“算法透明”,又不用从头写窗口系统。

1.3 模块划分:把职责分清楚,后面调试少一半痛苦

我在动手写代码前,先画了一张模块划分图,把所有职责按照“算法、数据、交互、显示”四个方向拆开:

  • 算法层:直线光栅化、圆/椭圆光栅化、多边形填充、Bezier 曲线、裁剪算法等。这一层不依赖任何 Qt 类型,输入输出都是自定义结构体,方便单元测试。
  • 数据层:维护当前画布中的所有图元对象(Shape 基类以及直线、圆等子类),每个图元保存自己的几何参数和样式属性。
  • 交互层:处理鼠标事件,把鼠标拖拽变成“正在预览的图元”,在释放时把预览图元提交给数据层。
  • 显示层:每次数据层变更后,把所有图元重新光栅化到 QImage,然后触发界面刷新。

这个分层在初期会显得有点“重”,但对中期调试和后期的扩展帮助非常大。比如你在做裁剪算法时,只需要盯着算法层的函数输入输出,完全不用管界面有没有闪屏;你在做交互时,也只需要关心事件状态机,不需要碰像素。

2. 核心算法拆解:直线、圆、曲线与填充实战

2.1 Bresenham 直线光栅化:为什么不用 DDA,以及怎么处理斜率

直线光栅化是这门课的“第一道坎”。教材里通常讲三种方法:数值微分法 DDA、中点画线法、Bresenham 算法。DDA 的实现最直观,核心就是利用斜率 step 一步步算 y 值,但 DDA 每一步都有浮点加法和取整操作,效率低且在某些坐标范围下会出现不均匀的像素分布。Bresenham 的做法是全程只用整数运算,通过误差项的正负决定 y 是否递增。

Bresenham 算法的核心思想可以这样理解:在像素网格上,从起点到终点,x 方向每前进一列,y 方向要么保持不动,要么前进一行。到底动不动,取决于当前点与理想直线的“垂直误差”是否超过 0.5 像素。误差项增量可以用整数维护,避免浮点。

我用的是处理任意斜率版本的实现,核心代码如下:

void drawLineBresenham(int x0, int y0, int x1, int y1, QImage& canvas, QRgb color) { int dx = abs(x1 - x0); int dy = abs(y1 - y0); int sx = (x0 < x1) ? 1 : -1; int sy = (y0 < y1) ? 1 : -1; int err = dx - dy; while (true) { setPixelSafe(canvas, x0, y0, color); if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } }

这里最需要注意的一点是:setPixelSafe一定要做边界检查。我一开始没加边界检查,画布外的像素写入会导致随机崩溃,因为 QImage 在越界写像素时行为是未定义的。另外,测试时建议把起终点调成各种方向:水平、垂直、主对角线、斜率大于 1、斜率小于 1、反向等,逐项确认像素点连成的线段没有断点。

2.2 中点画圆与椭圆:利用对称性把计算量降到八分之一

圆的光栅化如果老老实实遍历 x 求 y,会出现两个问题:一是水平方向像素稀、垂直方向像素密,曲线看起来不均匀;二是涉及 sqrt 计算,效率太低。经典方案是“中点画圆法”,利用圆的八对称性,只计算第一象限内从 (0, R) 到 (R/√2, R/√2) 的八分之一圆弧,然后通过对称变换生成全部像素。

中点画圆的核心是维护一个判别式 d,初始值为1 - R,每一步根据 d 的符号判断下一个点是走“正右方”还是“右下方”。我用的是教材中的整数版本:

void drawCircleMidpoint(int cx, int cy, int radius, QImage& canvas, QRgb color) { int x = 0; int y = radius; int d = 1 - radius; while (x <= y) { setPixelSafe(canvas, cx + x, cy + y, color); setPixelSafe(canvas, cx - x, cy + y, color); setPixelSafe(canvas, cx + x, cy - y, color); setPixelSafe(canvas, cx - x, cy - y, color); setPixelSafe(canvas, cx + y, cy + x, color); setPixelSafe(canvas, cx - y, cy + x, color); setPixelSafe(canvas, cx + y, cy - x, color); setPixelSafe(canvas, cx - y, cy - x, color); if (d < 0) { d += 2 * x + 3; } else { d += 2 * (x - y) + 5; y--; } x++; } }

我第一次写这个算法的时候,把 d 的更新公式记错了,结果圆在第二象限出现断层。排查了很久才发现是自己把d < 0d >= 0的更新式写反了。所以建议你写完代码后,先画一个半径很小的圆(比如 r=5)逐步打印 d 和 x,y 的值,跟教材的推导表对比一遍,确认无误后再画大圆。

圆画完,椭圆就可以在这基础上扩展。标准椭圆没有八对称性,只有四对称性,所以中点法需要维护两个区域:斜率绝对值小于 1 的区域和大于 1 的区域,分别用不同的判别式。这里有个容易踩的坑:椭圆的长短轴半径 a、b 不相等时,判别式的增量公式都要带上 a、b 的平方项,不能直接套圆的公式。

2.3 Bezier 曲线与多边形填充:数学表达与像素化

如果说直线和圆是图形学的“基本功”,那 Bezier 曲线就是“进阶题”。课程里一般要求实现三次 Bezier 曲线,定义是给定 4 个控制点 P0、P1、P2、P3,曲线上的点由参数 t 决定:

B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3

实际绘制时,最简单的办法是在 [0,1] 区间内按固定步长取 n 个 t 值,计算对应的点,然后用直线段连接相邻点。n 取多少合适呢?我试过 n=100 和 n=1000,肉眼几乎看不出区别,但性能上 n=100 明显更流畅。你可以根据控制点之间的距离自适应调整步长:控制点包围盒对角线越长,步长越密。

多边形填充算法我采用的是扫描线种子填充。思路是:先计算多边形每条边与当前扫描线的交点,把所有交点按 x 排序,然后成对填充像素区间。这里最容易出问题的是交点数量为奇数的情况,一般原因是顶点恰好落在扫描线上,导致顶点被重复计数。解决办法是采用“上开下闭”或“左开右闭”的像素规则,也就是当边与扫描线相交于顶点时,只在边的一侧计数,避免重复。我在实现时专门写了一个isVertexOnScanline的辅助函数来处理这种边界情况,最终填出来的多边形内部不会出现漏填或溢出。

3. 交互设计与图形变换:让系统真正可用

3.1 鼠标事件状态机:按下、拖拽、释放的逻辑闭环

图形系统的交互核心是“橡皮筋”效果:鼠标按下时确定图元的起点,拖拽过程中持续显示预览,松开时确认终点并生成正式图元。实现这个效果需要维护一个简单的状态机,状态包括:

  • Idle:空闲状态,鼠标按下后进入 Drawing 状态。
  • Drawing:正在拖拽,每个 MouseMove 事件都更新临时终点,并且触发重绘。
  • Done:MouseRelease 触发,把临时图元正式加入数据层,回到 Idle。

在 Qt 里我重写了mousePressEventmouseMoveEventmouseReleaseEvent三个方法。这里有一个非常关键的细节:在拖拽过程中,Qt 默认不会持续发送 MouseMove 事件,除非你在构造函数里调用setMouseTracking(true)。我刚开始没开这个开关,导致只有按住鼠标按钮时才能收到 move 事件,松开后曲线就断了。如果你发现拖拽时图形不跟随鼠标,第一个要检查的就是 mouseTracking 是否开启。

另一个容易忽视的点是:预览图元不应该直接写进正式图元列表,否则每次移动鼠标都会往列表里增加一个新对象,造成内存膨胀和重绘变慢。我的做法是为预览图元单独设一个成员变量m_previewShape,每次 MouseMove 时只更新这个对象,然后调用update()让整个画布重绘。重绘函数里先画正式图元,再画预览图元,这样用户看到的就是实时跟随的橡皮筋效果。

3.2 拾取与命中测试:用户点选图元背后的算法

画好了图元,下一个功能需求通常是“选中它、移动它”。选中的本质就是拾取,也就是判断鼠标点击坐标是否落在某个图元附近。最简单的拾取策略是距离阈值:

  • 直线:计算点到线段的距离,如果小于阈值(比如 5 像素),则判定命中。
  • 圆:计算点击点到圆心的距离与半径的差值绝对值,小于阈值即命中。
  • 曲线:可以对曲线做密集采样,然后计算点击点到每个采样点的距离,取最小值判断。

这个方案实现起来非常简单,但有一个小坑:对于填充图形,比如填充后的多边形,用户点选时可能希望点击多边形内部也能选中,这时就需要额外做一个点与多边形的包含测试。最经典的算法是射线法:从点击点向右发射一条水平射线,统计与多边形边的交点个数,如果交点数为奇数,则点在多边形内部。测试时记得覆盖“点击点在顶点上”和“点击点在水平边上”这两种边界情况,否则会出现错误的奇偶判断。

我实际测试下来,阈值 5 像素在普通分辨率屏幕上比较舒适,用户不会觉得太难点,也不会误触。如果你的系统支持缩放画布,记得把阈值除以缩放倍数,否则放大后拾取区域会偏大。

3.3 图形变换矩阵:从局部坐标到世界坐标

如果作业要求支持“移动、旋转、缩放选中图元”,那么你需要在图元对象里额外保存一个变换矩阵,或者更简单一点,保存图元的“局部坐标”并应用变换后得到“世界坐标”。

我用的是 3x3 齐次坐标矩阵,支持平移、旋转、缩放三种基本变换:

struct Matrix3x3 { double m[3][3]; static Matrix3x3 identity() { Matrix3x3 mat{}; mat.m[0][0] = mat.m[1][1] = mat.m[2][2] = 1.0; return mat; } static Matrix3x3 translation(double tx, double ty) { Matrix3x3 mat = identity(); mat.m[0][2] = tx; mat.m[1][2] = ty; return mat; } static Matrix3x3 rotation(double angle) { Matrix3x3 mat = identity(); double c = cos(angle), s = sin(angle); mat.m[0][0] = c; mat.m[0][1] = -s; mat.m[1][0] = s; mat.m[1][1] = c; return mat; } static Matrix3x3 scale(double sx, double sy) { Matrix3x3 mat = identity(); mat.m[0][0] = sx; mat.m[1][1] = sy; return mat; } };

需要说明的是,旋转变换矩阵默认是绕原点旋转的。如果你希望图元绕自身的中心点旋转,需要先平移到原点、旋转、再平移回去。这一步是很多同学丢分的地方:直接乘旋转矩阵,结果图元一边旋转一边跑位。

表达式应该是:

T(cx, cy) * R(angle) * T(-cx, -cy)

把这个组合矩阵作用到图元的所有控制点上,图元就会围绕自己的中心点旋转。我在实现变换交互时,是用鼠标拖拽角度来实时更新旋转矩阵的,效果很直观,而且代码逻辑集中在“计算矩阵”和“顶点应用矩阵”两个函数里,调试起来很清爽。

3.4 裁剪算法:为什么说裁剪是窗口系统的基石

图形学课程里裁剪是绕不开的。这个项目里我实现了 Cohen-Sutherland 线段裁剪算法,应用场景是以后如果要加“视口”功能,或者用户把图元拖出画布后再拖回来,系统能正确显示画布内的部分。

Cohen-Sutherland 的核心是区域编码:把平面按裁剪窗口的上下左右边界分成 9 个区域,每个区域用 4 位编码表示,然后对线段两个端点做编码判断:

  • 如果两端点的编码按位与不为 0,说明两端点都在同一外部区域,线段完全不可见,直接丢弃。
  • 如果两端点编码均为 0,说明完全在裁剪窗口内,保留。
  • 否则就是部分可见,需要求线段与窗口边界的交点,把线段缩短后继续判断。

实现时要注意浮点除法可能产生除零问题,如果线段的 dx 为 0,说明是垂直线,求交点时不能直接除以 dx。我第一次写的时候没考虑水平线和垂直线,结果这两种线段在裁剪时全部失效。

裁剪算法写完以后,建议用一组覆盖所有情况的测试用例来验证:完全在窗口内的线段、完全在外面的、横穿窗口的、起点在内终点在外的、起点在外终点在内的、以及恰好经过窗口顶点的。这几种情况全部通过,才说明裁剪逻辑是健壮的。

4. 实操过程与核心环节实现记录

4.1 工程搭建:Qt Widgets + QImage 画布的最小骨架

我用的 Qt 版本是 5.15,创建的是 Qt Widgets Application。整个工程的核心文件只有 4 个:主窗口类 MainWindow、画布控件类 CanvasWidget、图元基类 Shape 和三个子类、以及一个独立的 Algorithm 命名空间用来放光栅化函数。

主窗口界面非常简单,左侧是图元类型选择按钮(直线、圆、椭圆、多边形、曲线),中间是画布 QLabel,右侧是颜色选择和操作按钮(撤销、清空、保存图片)。我没有用 Qt Designer 拖界面,而是直接代码写布局,这样更容易控制逻辑。

画布控件部分,关键的成员变量包括:

class CanvasWidget : public QWidget { Q_OBJECT public: explicit CanvasWidget(QWidget* parent = nullptr); protected: void paintEvent(QPaintEvent* event) override; void mousePressEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override; private: QVector<std::shared_ptr<Shape>> m_shapes; std::shared_ptr<Shape> m_previewShape; ShapeType m_currentType; QColor m_currentColor; QPoint m_pressPos; QPoint m_currentPos; bool m_drawing = false; };

paintEvent 里做的事情非常简单:先调用rebuildImage()把 m_shapes 里的图元重新光栅化到一个 QImage 上(这一步是耗时操作,数据量大时考虑做增量更新),然后把 QImage 绘制到 widget 上。这样你操作的是“内存位图”,而屏幕上永远显示的是最新状态。

4.2 从“画到屏幕”到“持久化图元”:状态管理的关键差异

交互层踩过最大的坑,是把“绘制像素”和“保存图元”混为一谈。直接调用 drawLine 往 QImage 上画像素,确实能看到图形,但一旦窗口刷新(比如最小化再恢复),所有像素都会消失。因此必须让数据层保存“图元描述”,而不是保存“像素结果”。

在这个架构里,每次鼠标释放后,我会把新图元加入 m_shapes,然后调用clearCanvas()清空 QImage,再遍历 m_shapes 重新绘制所有图元。如果图元数量不大(几十到几百),这个方案完全够用,而且天然支持撤销和重绘。如果图元数量上千,可以考虑把绘制结果缓存到 QImage 里,只在图元增删时局部重绘,但课程作业通常不需要优化到这个程度。

撤销功能也受益于这个设计:我维护了一个QVector<QVector<std::shared_ptr<Shape>>> m_history作为历史栈,每次提交新图元之前,先把当前 m_shapes 的深拷贝压入栈。撤销时从栈顶弹出,恢复 m_shapes,然后触发一次全量重绘。注意这里必须是深拷贝,否则历史里的图元对象和当前对象共享同一块内存,改了当前对象历史也会变,撤销就失效了。

4.3 画布刷新与性能优化:setPixel 有没有更快的写法

QImage 的setPixel是按像素操作的,每调用一次都会检查坐标范围和格式转换,所以当你绘制一条包含几万个像素的曲线时,性能会明显下降。我的优化方案是调用bits()直接操作底层内存:

void setPixelSafe(QImage& image, int x, int y, QRgb color) { if (x >= 0 && x < image.width() && y >= 0 && y < image.height()) { uchar* line = image.scanLine(y); line[x * 4 + 0] = qBlue(color); line[x * 4 + 1] = qGreen(color); line[x * 4 + 2] = qRed(color); line[x * 4 + 3] = qAlpha(color); } }

这里有个前提:QImage 的格式必须设置为QImage::Format_ARGB32,每个像素占 4 字节,顺序是 BGRA。如果你用了其他格式,比如Format_RGB32,字节布局也不同,需要按实际格式调整偏移。直接操作内存虽然快,但要注意你的图像格式必须是 32 位的,否则line[x * 4]的偏移会错位,导致画出来的颜色全部错乱。

另外,如果画布很大(比如 1920x1080),每次全量重绘时把所有图元都重新光栅化一次,在大图元数量下会有卡顿。我的优化技巧是:维护两个 QImage,一个m_backgroundImage保存当前所有图元的光栅化结果,一个m_tempImage在拖拽时用来叠加预览图元。平时只更新 m_backgroundImage,拖拽预览时复制一份 background 到 tempImage,然后在 tempImage 上画预览图元。这样正式图元不会被反复重画,性能提升非常明显。

4.4 文件保存与读取:自定义 JSON 格式 vs 导出图片

课程作业做到后期,很自然会要求“把画布内容保存下来”。这里有两种截然不同的需求:

  • 保存为图片(PNG/JPG):适合导出最终效果,用 QImage::save 一行代码就能完成。
  • 保存为工程文件:适合把图元数据保存下来,下次打开还能继续编辑,这时需要把图元的类型、坐标、颜色、线宽等序列化到文件。

我实现的是 JSON 格式,用 QJsonDocument 把每个图元序列化成一个对象:

{ "type": "circle", "center": [320, 240], "radius": 80, "color": "#FF0000", "lineWidth": 2 }

加载时按 type 字段创建对应的子类对象。这个格式有两个好处:一是人眼可读,调试时可以直接打开文件查看坐标对不对;二是扩展性极好,以后加一个“填充颜色”字段,老文件依然能正常解析。

这里提醒一个坑:JSON 序列化浮点数时,如果直接输出 double,会有很多位小数,文件体积会膨胀。我建议保存前先对坐标取整或限制到小数点后 2 位,因为图像绘制是像素级的,小数精度意义不大,反而会拖累读写性能。

5. 常见问题与排查技巧实录

5.1 编译链接阶段:moc 文件缺失与头文件依赖

Qt 项目最常见的编译问题有两个。第一个是自定义类带了Q_OBJECT宏,但没有在 .pro 文件里正确包含对应头文件,导致 moc 文件没有生成。解决方法一般是在 .pro 里检查HEADERS +=是否包含了该类头文件,Qt Creator 在保存时会自动调用 moc,如果没生效,执行一次qmake再重新构建。

第二个问题是链接阶段报undefined reference to vtable,这通常是因为某个带 Q_OBJECT 的类的 moc 文件没有被重新生成,或者类定义里新增了 slot 但没有重新编译。遇到这类问题,先执行“清除项目、qmake、重新构建”三步曲,大多数能解决。如果还不行,检查类的析构函数和拷贝构造函数是否声明了但没有实现,这个也会导致 vtable 链接失败。

5.2 画面显示问题:图片模糊、坐标反转、颜色异常

如果你用 QLabel 显示 QImage,需要调用ui->label->setPixmap(QPixmap::fromImage(image))。不要直接把 QImage set 到 label 上,那样不会自动刷新。另一个常见问题是 QLabel 默认会缩放图片导致模糊,如果你希望 1:1 显示,需要把 label 的 sizePolicy 设置为固定大小,或者使用 QScrollArea 包裹。

坐标反转是我在实现圆和曲线时踩过的一个隐蔽的坑。屏幕坐标系 y 轴朝下,数学坐标系 y 轴朝上,如果你从鼠标事件里获取的坐标直接传给光栅化算法,那么用户向上拖动时圆的 y 坐标是减小的,看起来圆是“反着画”的。解决办法是定义一层逻辑坐标转换:在交互层把鼠标坐标转换为“世界坐标”,在光栅化层再把世界坐标转换为像素坐标。虽然麻烦,但这是做图形系统的正道,后续加缩放、平移会非常轻松。

颜色异常的问题一般出在 QImage 格式上。如果你创建图像时用了Format_MonoFormat_RGB16,后面 setPixel 的颜色通道就会错乱。我的建议是统一用Format_ARGB32,其他格式一律不用,能规避掉 80% 的颜色问题。

5.3 交互行为bug:拖拽断线、点选误触、撤销错乱

拖拽时看不到图形跟随,上面已经说过是setMouseTracking的问题,但还有一个隐蔽问题:如果你在mouseMoveEvent里做的操作比较重(比如每次都做全量重绘),鼠标移动时画面会一卡一卡,看起来像“断线”。解决方法是把预览图元的绘制放到 paintEvent 里完成,而不是在 mouseMoveEvent 里直接画到 QImage 上。

点选误触通常发生在图元密集区域。我一开始用的是“点击点是否在图元外接矩形内”作为选中的判定条件,结果矩形区域互相重叠,用户点击一条直线经常选到旁边的圆。后来改成精确的“点到图元距离”判定,误触率大幅下降。建议你在做拾取时,遍历顺序改为“最后绘制的图元优先判定”,这样用户可以很方便地选中重叠区域的上层图元。

撤销错乱的问题我在上面提过,根因是浅拷贝。这里再补充一个细节:如果你的 Shape 类里包含 QVector 等动态容器,默认拷贝构造函数是浅拷贝,两个对象会共享容器的数据指针。必须实现深拷贝的 clone 方法,或者用 std::shared_ptr 管理数据,把图元对象放进智能指针里,这样撤销历史里保存的每个对象都独立存在。

5.4 算法边界问题:圆弧断层、填充溢出、裁剪除零

这一节记录的是我在测试阶段专门整理的问题速查表,建议你自己做同样的事情,把每种算法容易出 bug 的边界情况列出来逐一验证。

症状可能原因排查方法
圆出现十字形缺口八对称性只画了四分之一,或者对应像素坐标写错检查对称变换时 x、y 是否互换正确
圆在某个方向半径明显偏大圆心坐标参与对称后出现偏移确认圆心 cx, cy 在每一组对称点中都保持不变
填充后边缘多出或漏掉一列像素扫描线交点奇偶计数出错检查顶点扫描线规则,采用上开下闭
线段裁剪结果多个水平或垂直端点除零或把边界交点计算错单独测试 dx=0 和 dy=0 的情况
Bezier 曲线弯曲方向不对控制点顺序颠倒确认 P0 到 P3 的顺序与用户点击顺序一致

圆弧断层还有一个常见原因:中点画圆法写错判别式初值。标准版本是 d = 1 - R,为什么不是 1.25 - R?因为我们要避免浮点。但如果你把初值写成 1 - R,在 R 特别大时可能会差一个像素,这是我做的大半径测试时发现的。更稳妥的写法是用浮点初值 1.25 - R,然后比较 d 与 0 的关系,但这样会引入浮点运算。绝大部分课程作业不会强求半径超过几千像素,所以整数版本够用,但你自己要清楚这个取舍。

填充算法如果使用递归种子填充,当多边形面积很大时,递归深度会非常大,甚至导致栈溢出。我的解决方法是改成显式栈的“扫描线种子填充”,用一个 std::stack 保存种子点。这是工程上常用的优化,不会改变算法正确性,只是把隐式递归改成了显式循环。

5.5 一点实用经验:写算法前先写测试用例,写完再写界面

如果你只打算从这篇文章里带走一条经验,那就是:先写算法,然后用独立的小测试程序验证,最后再集成到界面。

我一开始图省事,直接把算法写进 CanvasWidget 类里,一边写界面一边调算法。结果出现了一个 bug:画直线时看起来没问题,但画圆时偶尔出现颜色不对的像素。我误以为是光栅化算法的问题,查了大半天才发现是 QImage 颜色格式设置错误,跟圆算法毫无关系。

后来我把所有光栅化函数抽到独立的namespace raster里,写了一个简单的命令行测试程序,用 ReadBack 的方式检查像素坐标是否在期望位置。比如画一条从 (0,0) 到 (10,5) 的直线,程序输出所有实际被点亮的像素坐标,我可以直接对照 Bresenham 的推导过程,一眼看出哪里多了像素、哪里少了像素。这个习惯帮我省下的调试时间,至少是三分之一的开发周期。

图形学大作业难吗?难,但难不在某个算法看不懂,而在于把一整个系统串起来的时候,各种小问题会互相干扰。分开隔离、逐层验证,是这个项目最重要的工作方法。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询