ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

任意斜率直线绘制实战:Bresenham算法与常见坑解析

任意斜率直线绘制实战:Bresenham算法与常见坑解析 简介一份计算机图形学实验设计报告聚焦绘制任意斜率直线段的主题适合图形学课程设计、期末实验或算法入门学习者参考。报告以CLine直线类为主线详细给出了MoveTo()与LineTo()成员函数的声明和实现并基于中点Bresenham扫描转换算法针对斜率[-1,0]区间推导出初始误差项、判别条件与递推公式代码可在MFC视图的OnDraw函数中直接调用方便读者对照验证。文档结构完整包含实验目的、实验要求、程序核心代码、运行结果及心得体会等小节层次清晰便于按需查阅。资源为1个doc文档压缩包仅60KB轻量易用目前已有411人学习适合需要掌握Bresenham快速实现或参考完整实验文档的读者。1. 图形学里的“任意斜率直线段”一个看起来容易、做起来翻车的实验题很多上过计算机图形学实验课的人都见过这个题绘制任意斜率的直线段。多数人以为把教材里的 DDA 或 Bresenham 算法抄一遍就收工结果一测试就露馅——斜着画的线断成一截一截从右往左画直接消失负斜率时候图像像镜子一样反了试卷上那道“任意斜率”的加分项反而成了扣分项。这个实验设计的难点并不在算法原理而在你是否把“任意”两个字拆成了可落地的几种情况去处理。这篇文章就按我实际带实验课的习惯把斜率该怎么分类、主步进轴怎么选、像素为什么会出现空洞、怎么用一段不依赖图形库的代码验证结果讲清楚。适合计算机图形学课程设计的新手也适合想自己写软件渲染器但还没啃下光栅化这块硬骨头的开发者。2. 先立规矩像素坐标系、斜率分类和两个主流算法的选型理由2.1 像素网格与数学直线的偏差为什么要按斜率分两步走数学里的直线是连续曲线屏幕上是一堆离散像素点。画直线的本质就是在连续直线经过的像素网格里找一组“最接近真实直线”的像素。这里要注意接近的标准不是点到直线上最近的像素而是让像素中心尽量贴近数学直线并且不能出现可见的断裂。我见过不少同学第一个版本是这样写的x 从起点到终点每次加 1y round(y0 m * (x - x0))。测试第一条对角线还行斜率 m 在 0 到 1 之间时x 每步进一个像素y 也跟着进一个像素线是连续的。一旦 m 2.5x 每次加 1y 要跨过两三个像素行如果只保留取整后的一个点中间那些y行就直接被跳过去了画出来就是一段段虚线。反过来如果让 y 每次加 1x 的变化小于 1 时也会出现同样的空洞。所以处理任意斜率的第一个原则就是主步进轴必须是变化量更大的那个坐标轴。具体来说给定起点 (x0,y0) 和终点 (x1,y1)dx x1-x0dy y1-y0。当 |dx| ≥ |dy| 时x 的变化占主导每次循环 x 步进 1或 -1y 按照误差项决定是否步进当 |dx| |dy| 时y 的变化占主导每次循环 y 步进 1或 -1x 跟着误差走。这样无论斜率绝对值是 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 的取整用 roundBresenham 的决策变量本身已经隐含取整规则DDA 在循环里要做浮点加法而 Bresenham 的误差项被整理成整数增量更稳定。从实验工作量来看DDA 适合画长度不超过几百像素的示例线直观展示“直线被离散化”的效果Bresenham 适合做性能对比和扩展到圆、椭圆的光栅化。如果你被要求绘制“任意斜率”的直线我建议主线用 Bresenham因为浮点误差在极端斜率如 m100 时会暴露出更多边界问题而整数版本在这些场景下更可控。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 画直线并输出 PPM4.1 从零写一个任意斜率 Bresenham完整代码与逐段说明现在用一个不依赖任何图形库的方案把上面的算法落地。输出格式用 PPM这是最原始的图像格式之一一个文件头加上 RGB 像素数据就能被大多数看图工具打开。这样可以专注在直线光栅化本身不用折腾 OpenGL 或窗口库。完整代码我放在下面它生成一张 400x300 的画布画了几个覆盖不同斜率的直线段保存为 out.ppm。#include cstdio #include cstdlib #include vector struct Color { unsigned char r, g, b; }; std::vectorColor 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 -stdc11 main.cpp -o draw_line ./draw_line第一次运行后当前目录会出现 out.ppm。查看它可以用 ImageMagick 转换convert out.ppm out.png open out.png如果你没有 ImageMagickLinux 下可以用 feh、displayWindows 下可以用 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 或 -1y 方向的 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 邻域连续性检查像素点集肉眼检查只能覆盖明显断线但边界情况仍可能漏掉。我写过一个很小的校验函数把所有被直线点亮的像素收集到 vectorpairint,int 里然后逐个检查相邻两个点之间的距离。如果任意两个连续输出的点之间在 x 或 y 方向上的差超过 1那这里一定有问题。这个函数实现起来很简单关键是它能直接捕获步进轴选择错误的现象bool checkContinuity(const std::vectorstd::pairint,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 邻域连续性检查并且覆盖测试矩阵里每一组点对那之后把这条直线封装成渲染器里的基础绘图函数基本不会再出幺蛾子。后来我养成了一个新习惯每写一个光栅化算法不用眼睛观察直接先跑连续性检查。这个小习惯省了我不少在实验课上被老师问“你确定这条线连上了吗”的尴尬希望帮到你。本文还有配套的精品资源点击获取
返回列表