☰
任意斜率直线绘制实战:Bresenham算法与常见坑解析
2026/9/30 8:58:39 网站建设 项目流程

简介:一份计算机图形学实验设计报告,聚焦绘制任意斜率直线段的主题,适合图形学课程设计、期末实验或算法入门学习者参考。报告以CLine直线类为主线,详细给出了MoveTo()与LineTo()成员函数的声明和实现,并基于中点Bresenham扫描转换算法,针对斜率[-1,0]区间推导出初始误差项、判别条件与递推公式,代码可在MFC视图的OnDraw函数中直接调用,方便读者对照验证。文档结构完整,包含实验目的、实验要求、程序核心代码、运行结果及心得体会等小节,层次清晰,便于按需查阅。资源为1个doc文档,压缩包仅60KB,轻量易用;目前已有411人学习,适合需要掌握Bresenham快速实现或参考完整实验文档的读者。

1. 图形学里的“任意斜率直线段”:一个看起来容易、做起来翻车的实验题

很多上过计算机图形学实验课的人都见过这个题:绘制任意斜率的直线段。多数人以为把教材里的 DDA 或 Bresenham 算法抄一遍就收工,结果一测试就露馅——斜着画的线断成一截一截,从右往左画直接消失,负斜率时候图像像镜子一样反了,试卷上那道“任意斜率”的加分项反而成了扣分项。这个实验设计的难点并不在算法原理,而在你是否把“任意”两个字拆成了可落地的几种情况去处理。这篇文章就按我实际带实验课的习惯,把斜率该怎么分类、主步进轴怎么选、像素为什么会出现空洞、怎么用一段不依赖图形库的代码验证结果讲清楚。适合计算机图形学课程设计的新手,也适合想自己写软件渲染器但还没啃下光栅化这块硬骨头的开发者。

2. 先立规矩:像素坐标系、斜率分类和两个主流算法的选型理由

2.1 像素网格与数学直线的偏差:为什么要按斜率分两步走

数学里的直线是连续曲线,屏幕上是一堆离散像素点。画直线的本质,就是在连续直线经过的像素网格里找一组“最接近真实直线”的像素。这里要注意,接近的标准不是点到直线上最近的像素,而是让像素中心尽量贴近数学直线,并且不能出现可见的断裂。

我见过不少同学第一个版本是这样写的:x 从起点到终点每次加 1,y = round(y0 + m * (x - x0))。测试第一条对角线还行,斜率 m 在 0 到 1 之间时,x 每步进一个像素,y 也跟着进一个像素,线是连续的。一旦 m = 2.5,x 每次加 1,y 要跨过两三个像素行,如果只保留取整后的一个点,中间那些y行就直接被跳过去了,画出来就是一段段虚线。反过来,如果让 y 每次加 1,x 的变化小于 1 时,也会出现同样的空洞。所以处理任意斜率的第一个原则就是:主步进轴必须是变化量更大的那个坐标轴。

具体来说,给定起点 (x0,y0) 和终点 (x1,y1),dx = x1-x0,dy = y1-y0。当 |dx| ≥ |dy| 时,x 的变化占主导,每次循环 x 步进 1(或 -1),y 按照误差项决定是否步进;当 |dx| < |dy| 时,y 的变化占主导,每次循环 y 步进 1(或 -1),x 跟着误差走。这样无论斜率绝对值是 0.1 还是 10,每个像素行或列上都能留下一个点。

另外要注意屏幕坐标和数学坐标的方向。数学里的 y 轴向上,而图像文件或窗口系统的 y 轴通常向下。如果实验使用自己申请的帧缓冲,行号从上往下增大,写代码时要么把起点和终点的 y 都换算成“height - 1 - y”,要么心里始终记住屏幕 y 方向是反转的。这个细节很容易被忽略,但它会直接影响直线的视觉走向。

2.2 DDA 与 Bresenham 选型:精度、速度与实验报告想要的差别

这题最常提的两个算法是 DDA 和 Bresenham。DDA,全称数字微分分析器,思路是让 x 和 y 同时按长度比例步进,每次把坐标累加后取整。代码最直白,但每一步都有浮点数和取整运算,长线段上容易出现累积误差。Bresenham 算法把决策建立在整数误差项上,每一步只有加减和比较,适合在硬件上跑,也是图形学课程要求的经典算法。

实际做实验时我通常建议两条腿走路:先用 DDA 写一版“对照实现”帮助验算思路,再用 Bresenham 写正式版本。这样实验报告里可以比对其结果,还能回答“为什么最终选型是 Bresenham”这类追问。两者的差别可以简单分成三点:DDA 需要浮点除法得到斜率,Bresenham 只需要算 dx 和 dy 的绝对值;DDA 的取整用 round,Bresenham 的决策变量本身已经隐含取整规则;DDA 在循环里要做浮点加法,而 Bresenham 的误差项被整理成整数增量,更稳定。

从实验工作量来看,DDA 适合画长度不超过几百像素的示例线,直观展示“直线被离散化”的效果;Bresenham 适合做性能对比和扩展到圆、椭圆的光栅化。如果你被要求绘制“任意斜率”的直线,我建议主线用 Bresenham,因为浮点误差在极端斜率如 m=100 时会暴露出更多边界问题,而整数版本在这些场景下更可控。

3. 任意斜率怎么应付:四象限划分与对称映射

3.1 把斜率拆成四种类型:|m|<1、|m|>1、水平和垂直

既然要求任意斜率,就不要只盯着一个第一象限。我习惯在实验开始先做一次输入预处理,不直接算斜率,而是比较 dx 和 dy 的绝对值,这样能避开斜率为无穷大的情况。分类结果如下:

水平线:dy 为 0,此时 y 不变,x 从起点到终点步进即可。垂直线:dx 为 0,此时 x 不变,y 步进。普通斜线里,如果 |dx| ≥ |dy|,说明直线更“平”,x 是主步进轴;如果 |dx| < |dy|,说明直线更“陡”,y 是主步进轴。

这样分类的原因很简单:我们最终想要的是一串八连通或四连通的像素点,主步进轴上的值每次固定改变 1,另一个轴根据误差项来决定要不要改变。只要主步进轴选择正确,像素点就一定是连续的。这个分类也可以写成很干净的逻辑判断:

int dx = x1 - x0; int dy = y1 - y0; int absDx = abs(dx); int absDy = abs(dy); if (absDx >= absDy) { // 以 x 为主步进轴 } else { // 以 y 为主步进轴 }

代码里的 absDx 和 absDy 分别保存两个方向上的绝对增量,目的是让后续算法不关心直线是从左到右还是从右到左,只关注长度大小。这一步做完,任意斜率已经被拆成了“x 主导”和“y 主导”两个分支,后面处理起来就非常顺。

3.2 用对称映射把负斜率变回正斜率:x/y 交换与终点重排

很多教材里讲 Bresenham 只讲第一象限 0 到 1 之间的斜率,实验一要求任意斜率,就得做映射。常见的做法是八分圆对称:把所有斜率都映射到第一象限扫过的那段区域,画图时再根据原始象限还原。另一种更直接的做法是显式分类后,在循环里通过 sx、sy 两个符号变量来控制方向。

我更喜欢一个更简洁的统一写法:它对所有斜率都有效,而且在每一步同时判断 x 和 y 是否需要步进。你可以把它理解为:把 dx、dy 的绝对值当作两个独立方向的增量,用 sx、sy 表示方向符号,用错误项 err 同步两个方向的进度。它的核心逻辑如下:

int dx = abs(x1 - x0); int sx = x0 < x1 ? 1 : -1; int dy = -abs(y1 - y0); int sy = y0 < y1 ? 1 : -1; int err = dx + dy; while (true) { setPixel(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; } }

参数含义是:dx 是 x 方向的绝对距离,dy 故意取负值从而可以用一次加法判断两个方向的误差;sx 和 sy 是步进方向,起点在终点右侧时 sx 为 -1,在终点下方时 sy 为 -1。err 初始化为 dx + dy,这相当于把 x 和 y 方向的总距离换算成同一个误差坐标系。每次循环先用 e2 = 2 * err 判断当前是否需要先步进 x 或 y,如果两个条件都满足,则同时步进 x 和 y,这种情况对应斜率绝对值等于 1 或像素需要走斜对角。

这个统一算法实际上已经隐式处理了“交换 x/y”这件事:当 |dy| > |dx| 时,y 方向步进得更频繁,等价于以 y 为主步进轴;当 |dx| > |dy| 时,x 方向步进得更频繁。因为误差项是同时管理两个方向的,所以不需要再显式交换坐标,这很适合写进实验报告“算法设计”一节。不过我给新手讲的时候,会先要求用分支版本画出四个象限,再对比这个统一版本,不容易绕晕。

4. 动手搭一个最小实验环境:C++ 画直线并输出 PPM

4.1 从零写一个任意斜率 Bresenham:完整代码与逐段说明

现在用一个不依赖任何图形库的方案把上面的算法落地。输出格式用 PPM,这是最原始的图像格式之一,一个文件头加上 RGB 像素数据就能被大多数看图工具打开。这样可以专注在直线光栅化本身,不用折腾 OpenGL 或窗口库。

完整代码我放在下面,它生成一张 400x300 的画布,画了几个覆盖不同斜率的直线段,保存为 out.ppm。

#include <cstdio> #include <cstdlib> #include <vector> struct Color { unsigned char r, g, b; }; std::vector<Color> framebuffer; int width = 400; int height = 300; void setPixel(int x, int y, const Color &c) { if (x < 0 || x >= width || y < 0 || y >= height) return; framebuffer[y * width + x] = c; } void drawLine(int x0, int y0, int x1, int y1, const Color &c) { int dx = abs(x1 - x0); int sx = x0 < x1 ? 1 : -1; int dy = -abs(y1 - y0); int sy = y0 < y1 ? 1 : -1; int err = dx + dy; while (true) { setPixel(x0, y0, c); if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 >= dy) { err += dy; x0 += sx; } if (e2 <= dx) { err += dx; y0 += sy; } } } void writePPM(const char *filename) { FILE *fp = fopen(filename, "wb"); fprintf(fp, "P6\n%d %d\n255\n", width, height); fwrite(framebuffer.data(), 3, framebuffer.size(), fp); fclose(fp); } int main() { framebuffer.resize(width * height, Color{255, 255, 255}); Color black = {0, 0, 0}; drawLine(10, 10, 200, 50, black); // 斜率小于 1 drawLine(10, 100, 180, 280, black); // 斜率大于 1 drawLine(10, 200, 250, 150, black); // 负斜率 drawLine(10, 10, 150, 10, black); // 水平 drawLine(200, 10, 200, 200, black); // 垂直 writePPM("out.ppm"); printf("done\n"); return 0; }

这段代码里 framebuffer 用 vector 模拟一整块显存,setPixel 负责把某个坐标写入对应位置,并且做了边界裁剪,防止画线时越界访问。drawLine 直接用上一节的统一 Bresenham 算法,五个 drawLine 调用分别覆盖了四种情况:低斜率、高斜率、负斜率、水平线和垂直线。writePPM 写文件头,注意 P6 格式里每个像素占三个字节,所以需要二进制方式打开文件。

代码逻辑上,最容易被忽略的是 writePPM 里的 fwrite 长度参数:framebuffer 是 Color 结构体数组,每个 Color 三个字节,fwrite 的第二个参数是单元宽度 3,第三个参数是元素个数 framebuffer.size()。如果这里传成 framebuffer.size() * 3,表面看起来一样,但当你把 Color 结构体对齐成 4 字节时就会出错。建议从一开始就按这种写法固定下来。

4.2 用命令行编译和查看结果:最小命令与参数说明

这个实验不需要装额外库,只要系统里有 C++ 编译器。Linux 或 macOS 下进入源码目录执行:

g++ -std=c++11 main.cpp -o draw_line ./draw_line

第一次运行后当前目录会出现 out.ppm。查看它可以用 ImageMagick 转换:

convert out.ppm out.png open out.png

如果你没有 ImageMagick,Linux 下可以用 feh、display,Windows 下可以用 IrfanView 或 Photos 打开 ppm,也有的编辑器直接支持 PPM。可以用任意现代 Python 环境的 PIL 打开它。核心是要确认 out.ppm 文件头里是否有“P6”和正确的宽高,否则图像显示会错乱。

参数说明集中在 drawLine 和 writePPM 的函数签名里:drawLine 的 x0、y0 是起点,x1、y1 是终点,color 是这条线的颜色;writePPM 的 filename 是输出路径;main 里的 width 和 height 影响整张画布的大小。你会发现修改起点和终点就能产生各种斜率,这是实验设计里最方便的部分:把五组点对替换成命令行参数,可以让测试者在不重新编译的情况下输入任意直线。如果实验报告要求展示不同斜率的效果,直接在 main 里多画几条不同颜色的直线即可。一个常见做法是把画布设为 800x600 并让用户传入参数,输出时再把直线坐标归一化到画布范围,这个扩展留给读者自己试。

5. 直线绘制避坑与排查:丢点、反转和上下颠倒的常见原因

5.1 现象:斜率大于 1 时直线变成虚线

这是最典型的翻车现场。直线的斜率是 2.5,屏幕上的 x 每向右走一格,y 应该向上走两格半。如果你的循环仍然是每轮只让 x 加 1,然后 y = y0 + m * (x - x0) 取整,那每一个 x 只有一个像素值,中间的 y 行会被跳过去,表现为一条每隔几个像素才有一个点的虚线。

原因就是没有选择正确的步进轴。当 |dy| > |dx| 时,y 方向变化更快,应该让 y 作为循环里的固定步进变量,x 随着误差走。解决方法是先比较 abs(dx) 和 abs(dy),abs(dy) 更大时交换 x 与 y 的角色,或者直接用上一章统一算法的符号版本,让误差项自动处理两个方向的步进频率。我自己的习惯是直接用统一算法,它能自然避免这个问题。

5.2 现象:从右往左画时直线消失

我见过一个同学把第一象限 Bresenham 写好,测试时画 (0,0) 到 (10,5) 正常,改成 (10,5) 到 (0,0) 之后屏幕上什么都没有。原因是他的循环里 x 总是执行 x++,终点 x 小于起点 x 时,循环条件 x <= x1 永远不成立,循环体一次都没进。

原因的根源在于没有做方向重排。解决方法是先判断起点与终点的相对位置,给 x 方向的步长 sx 赋值为 1 或 -1,y 方向的 sy 同理。统一算法里 sx 和 sy 已经处理了这个。如果你想用分类版本,就得在交换主步进轴之前,先把起点、终点按主步进方向调整为从小到大。实际操作时我会写一个 helper 函数,把 dx、dy 变成绝对值,再记录方向变量,这会比交换坐标更不容易出错。

5.3 现象:负斜率的直线画成了镜像

只处理了 0 到 1 斜率的代码,遇到斜率 -0.5 时,画出来的线是向右下角,而不是期望的右上角。原因是 y 的变化方向判断错误——你把负斜率当成正斜率,对 dy 取了绝对值,却忘了屏幕 y 轴朝下,应该把 sy 设为 -1。

正确处理方式是单独提取 dy 的符号。当 y1 - y0 < 0 时,说明 y 要减小,sy 设为 -1;反之设为 +1。对于 dx 也一样。如果你坚持用“斜率 m”计算,就得处理 m 的符号并把负号带进 y 的增量里,这会多一层浮点风险。我用整数 abs 加 sy 的方式实现,符号判断和绝对值计算分离,几乎不会出错。

5.4 现象:PPM 图像上下颠倒

代码里用数学坐标系画线,起点坐标越往上 y 越大。但 PPM 格式第一行写的是图像顶部,所以按数学坐标填充会导致整幅图上下翻转。比如你在数学坐标里从 (0,0) 画到 (100,100),这条线本应从左上到右下,显示出来却成了从左上到右上。

解决方法是明确屏幕坐标约定:要么在写入 framebuffer 时把 y 转换成 height - 1 - y,要么干脆接受屏幕坐标,把直线测试的起点终点设计成屏幕坐标系下的点。我一般会在实验报告开头说明一句“本实验使用屏幕坐标系,原点在左上角,y 向下增长”,然后在验证时用一张中心有十字的测试图观察方向。第 4 章代码里如果直接替换主函数测试点,需要留意这个约定。

5.5 现象:浮点累加在长直线末尾出现偏移

用 DDA 时,如果起点很大或终点距离很远,每轮循环累加的浮点增量经过几百次后会漂移,导致终点附近最后一两个像素偏离正确位置。Bresenham 是整数版本,不会出现这种问题,但 DDA 作为对照实现也得出正确结果。

解决方法是做“像素连续性检查”,也就是算一下画出的所有相邻像素之间最大增量是不是 1。如果出现了最大到 2 的跳变,说明算法在某个区段漏了像素。这种检查可以写成一个独立函数,输入像素点集,输出是否连续。我通常既做连续性检查,也做终点是否被覆盖的断言,两条验证一起过,基本之后在实验课上就没再翻过车。

6. 进阶技巧:用测试矩阵和像素连续性验证画线函数

6.1 设计一个覆盖所有斜率情形的测试矩阵

只测第一象限一定不够。我建议把测试点对整理成一张表,每条线标注“预期主导轴”和“预期方向”,跑完后用肉眼或代码比对实际输出。下面这套矩阵是我一直沿用的一套基础用例:

起点终点特点
(0,0)(100,25)低斜率,x 主导
(0,0)(25,100)高斜率,y 主导
(50,50)(10,20)负斜率,x 主导
(50,50)(20,10)负斜率,y 主导
(0,0)(100,100)对角,斜率为 1
(0,0)(100,0)水平
(50,20)(50,180)垂直

测试时把这些点对以参数形式传给 drawLine,输出一张图上同时画完,肉眼能立即发现方向是否异常。更重要的是,把这套矩阵写进实验报告,老师就清楚你已经考虑过“任意斜率”的完整情况,而不是只碰巧处理了第一象限。

6.2 用 8 邻域连续性检查像素点集

肉眼检查只能覆盖明显断线,但边界情况仍可能漏掉。我写过一个很小的校验函数:把所有被直线点亮的像素收集到 vector<pair<int,int>> 里,然后逐个检查相邻两个点之间的距离。如果任意两个连续输出的点之间在 x 或 y 方向上的差超过 1,那这里一定有问题。

这个函数实现起来很简单,关键是它能直接捕获步进轴选择错误的现象:

bool checkContinuity(const std::vector<std::pair<int,int>>& pts) { for (size_t i = 1; i < pts.size(); i++) { int dx = abs(pts[i].first - pts[i-1].first); int dy = abs(pts[i].second - pts[i-1].second); if (dx > 1 || dy > 1) return false; } return true; }

这个检查的严格程度是允许相邻像素在同一行/列或斜对角,也就是 8 邻域连续的。如果你希望得到的是四连通线段,可以把条件改成 dx + dy <= 1。我自己的经验是,只要画线函数能通过 8 邻域连续性检查,并且覆盖测试矩阵里每一组点对,那之后把这条直线封装成渲染器里的基础绘图函数,基本不会再出幺蛾子。后来我养成了一个新习惯:每写一个光栅化算法,不用眼睛观察,直接先跑连续性检查。这个小习惯省了我不少在实验课上被老师问“你确定这条线连上了吗”的尴尬,希望帮到你。

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

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

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

立即咨询