☰
图形学实验:DDA、中点画线与Bresenham直线生成算法详解
2026/10/14 18:20:20 网站建设 项目流程

这学期的图形学课,第二次实验就撞上了直线生成算法。说实话,没动手之前我以为画一条直线无非就是把起点和终点连起来,真正被要求“不能调用现成绘图函数、必须用像素把直线铺出来”的时候,才发现问题比预想中多得多。屏幕是一个由离散像素组成的网格,而理想直线是连续的,如何让离散的点列尽可能贴近连续直线的形状,还要兼顾计算速度,这就是直线生成算法存在的意义,也是图形学里所有光栅化工作的起点。

这次实验我完整对比了三种经典算法:DDA、中点画线法和Bresenham,每一步都自己写了代码,也在屏幕上做了可视化验证。这篇内容适合正在学计算机图形学的人、刚接触光栅化概念的开发者,以及想把渲染管线底层搞清楚的读者。我的体会是:这个实验的价值不在背代码,而在于把“离散与连续”“浮点累加与整数误差”这些图形学里的核心问题真正想透。

1. 直线生成到底在解决什么问题

1.1 光栅显示的离散像素模型

先回到最本质的问题:显示器的屏幕上,图像都是由一个个像素点组成的。以常见的1920×1080显示设备为例,水平方向有1920个像素点,垂直方向有1080个像素点,所有画面都在这个二维网格上呈现。所谓“画一条直线”,在现实世界中可以拿尺子和笔连续地画,但在数字设备上,我们只能在网格中选择若干像素,把它们点亮后让观察者的大脑自动把点阵“脑补”成一条线。

这里就出现了一个矛盾:理想直线是连续的,像素坐标是离散的。从(x0, y0)到(x1, y1)的线段,理论上由无穷多个点组成,但屏幕上只能显示有限数量的像素。于是核心问题变成:如何选出一组像素,让它们在视觉上最接近那条连续的理想线段?选得太稀疏,线会断裂;选得不均匀,线会歪斜;选得太多,又浪费计算资源。直线生成算法就是一套决策规则,告诉程序每一步应该点亮哪个像素。

稍微往后想一步:直线生成不只是画线段这么简单。三角形栅格化时,要先判断像素是否位于三角形内;字体轮廓显示时,曲线段也要转换成像素路径;CAD软件里的辅助线和网格线,本质上都在做类似的事情。理解了直线的像素选择逻辑,后面学多边形填充、抗锯齿、纹理映射都会轻松很多。

1.2 三种经典算法的选型对比

教材和实验里绕不开三种算法:DDA、中点画线法、Bresenham。它们之间的关系不是彼此孤立的三个公式,而是一条不断优化、不断向整数运算和效率靠拢的演进路线。

DDA是最直观的增量算法。它的思路很简单:既然直线方程是y = kx + b,那就按斜率k逐步推进,x每次加1时,y就累加一个斜率k,然后取整得到像素坐标。优点是容易理解、写起来快,缺点也更明显:涉及浮点加法和取整,循环次数多时,浮点误差会不断累积,可能导致线的末端偏离预期位置。

中点画线法换了一个角度,不再显式计算直线上的点,而是通过判断“两个候选像素的中点与理想直线的位置关系”来选择下一个点亮点。它用直线方程构造一个判别式,通过判别式的符号做决策,把乘除法全部避开了,用到的运算大幅简化。

Bresenham把误差思想发挥到极致。它直接维护一个整数误差项,每走一步更新误差值,再根据误差是否超过阈值来决定往哪个方向走。整条线从头到尾没有浮点运算,只有整数加减、移位和比较,非常适合在计算机上高效执行。在很多教材里,Bresenham和中点画线法被描述成两种算法,实际上它们在常见斜率范围内的决策逻辑可以等价,区别在于推导角度不同:中点画线法从直线方程出发,Bresenham从累计误差出发。

我的选择建议很明确:实验对比三种都写,实际工程里优先用Bresenham。但别急着把DDA丢掉,理解DDA的增量思想能帮你建立坐标系判断和步进方向的概念,后面调试其他两种算法时会很管用。

2. 核心算法原理推导与要点拆解

2.1 DDA算法

DDA的全称是Digital Differential Analyzer,数字差分分析器。它的核心是“选一个主步进方向,另一个方向按斜率增量累加”。为了方便解释,先设两个端点为(x0, y0)和(x1, y1),斜率k = dy / dx,其中dy = y1 - y0,dx = x1 - x0。

关键问题来了:到底让x每次加1,还是让y每次加1?这取决于斜率的绝对值。当|k|小于等于1时,直线比较平缓,x方向的变化量更大,应该让x每次加1,y每次加k。当|k|大于1时,直线比较陡峭,如果还按x方向步进,会出现什么问题?我最初实验时就踩了这个坑:画一条斜率为2的直线,让x从起点每次加1、y每次加2,得到的是(0,0)、(1,2)、(2,4)这样一串稀疏的点,中间(1,1)、(2,2)、(2,3)这些位置全是空洞,看起来根本不像线。正确做法是交换主方向:让y每次加1,x每次加1/k,这样每个整数y都对应至少一个像素,点列才是连续的。

为了避免每次写代码都要判断斜率,我用了一个通用形式:先算出dx和dy的绝对值,选择绝对值更大的方向作为步进方向,循环次数就等于这个较大绝对值。代码如下:

void drawLineDDA(int x0, int y0, int x1, int y1) { int dx = x1 - x0; int dy = y1 - y0; int steps = abs(dx) > abs(dy) ? abs(dx) : abs(dy); float xInc = (float)dx / steps; float yInc = (float)dy / steps; float x = x0; float y = y0; for (int i = 0; i <= steps; i++) { setPixel((int)(x + 0.5f), (int)(y + 0.5f)); x += xInc; y += yInc; } }

这里用(int)(x + 0.5f)代替(int)x,是为了做四舍五入而不是纯截断。如果直接取整,很多本应显示在整数像素附近的点会落到边缘,整条线看起来会偏离理想位置。你也可以用floor(x + 0.5f),效果相同,但要注意负数的取整方向。DDA的优点是代码少,跑一遍就能直观看到它是怎么一步一步逼近理想直线的;坏处也明显,每绘制一个像素都要累加浮点数,画一条很长的线时浮点误差会慢慢积累,终点可能偏离几个像素。

2.2 中点画线法的判别式思路

中点画线法的名字已经把核心说出来了:取两个候选像素的中点,判断中点在理想直线的哪一侧。下面以|k|小于等于1、且dx大于0的情况来推导,其他情况可以利用对称性转换。

理想直线可以用隐式方程F(x, y) = ax + by + c = 0表示,其中a = y0 - y1,b = x1 - x0,c = x0 * y1 - x1 * y0。这个方程有一个性质:F(x, y)大于0时,点(x, y)在直线的一侧;F(x, y)小于0时,点在另一侧;等于0时正好在直线上。

假设当前已经确定了像素点(x, y),下一步候选点是(x+1, y)和(x+1, y+1),它们的中心点M = (x+1, y+0.5)。把M代入F(x, y),观察结果的正负:如果F(M)大于0,说明M在直线上方,此时理想直线更靠近下方的候选点(x+1, y);如果F(M)小于等于0,则选择上方的候选点(x+1, y+1)。这个过程不需要计算直线上点的具体坐标,只需要做一次符号判断,非常巧。

不过,如果每一步都重新计算F(M),需要做乘法和加法,效率并不理想。聪明的做法是采用增量式更新:设判别式d = F(x+1, y+0.5),通过数学推导可以写出d的递推公式。假设当前d已经算好,当选择右方点时,d_new = d + 2 * dy;当选择右上方点时,d_new = d + 2 * (dy - dx)。初始d0 = 2 * dy - dx。这样整个循环内部只剩整数乘法和加法,乘法还是乘2,计算机里可以用移位实现。

代码可以这样写:

void drawLineMidPoint(int x0, int y0, int x1, int y1) { int dx = x1 - x0; int dy = y1 - y0; int d = 2 * dy - dx; int dE = 2 * dy; int dNE = 2 * (dy - dx); int x = x0, y = y0; setPixel(x, y); while (x < x1) { if (d < 0) { d += dE; x++; } else { d += dNE; x++; y++; } setPixel(x, y); } }

注意,这段代码只适用于第一象限且|k|小于等于1的直线。要处理所有方向,最稳妥的方案是先把斜率的绝对值统一到0到1之间,画完后利用方向对称性把结果映射回原来的区域。具体做法是:如果|dx|小于|dy|,就交换x和y的角色;如果x1小于x0,就交换两个端点的顺序。映射关系处理起来有些繁琐,但原理不难。

2.3 Bresenham的误差项与整数运算

Bresenham算法在思想上可以理解为“用一条实际绘制像素的误差折线去逼近理想直线”。它不再显式维护中点判别式,而是维护一个整数误差项,表示当前像素离理想直线的偏差,每走一步都更新误差,根据误差符号决定下一步到底往哪个方向前进。

对于第一象限、斜率在0到1之间的直线,常见教科书写法是:

假设当前像素为(x, y),决策参数为p。初始p = 2 * dy - dx。每走一步绘制当前点,然后判断:如果p小于0,下一个点选(x+1, y),p更新为p + 2 * dy;否则选(x+1, y+1),p更新为p + 2 * (dy - dx)。你可能已经发现了,这和前面中点画线法的递推式长得几乎一样。所以我在实验笔记里专门记了一句:在0到1斜率的范围内,中点画线法和Bresenham实际上是同一决策逻辑的不同表达,只是中点画线法从点到直线的几何距离切入,Bresenham从累计误差切入。

如果要适应任意方向的直线,我推荐用下面这个统一形式,它避免了分象限讨论带来的各种逻辑错乱:

void drawLineBresenham(int x0, int y0, int x1, int y1) { 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) { setPixel(x0, y0); if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } }

这段代码是我调过几次之后最喜欢的版本。sx和sy记录了x、y两个方向的步进方向,err代表当前像素与理想直线在横向和纵向上的误差差。两个if判断并行执行,意味着在一次循环里可能只改x、只改y,也可能两个方向都改,这样就自然覆盖了所有斜率。初看起来有点绕,但配合几组具体数据手动跑一遍,很快就能理解:它本质上是在一个误差平面上做“最小偏差逼近”,视觉上最接近理想直线的像素组合。

理论上一旦理解了各个算法,写起来还行。但这个实验真正花时间的地方在于:把所有方向、所有斜率、所有边界情况都测一遍,并确保显示出来的线不歪、不断、不重影。

3. 实操环境与代码实现详解

3.1 实验环境的搭建

我这次用的环境很简单:一个支持C++的控制台工程用来跑字符画版本,再配了一套OpenGL的GLUT接口库用来做可视化验证。GLUT是OpenGL下面常用的窗口工具库,初始化窗口的代码很简洁,适合做图形学实验展示。

如果你手头暂时不方便装完整图形环境,有一个更轻量的验证方法:先把所有像素写进一个二维字符数组,用*表示点亮、.表示空白,最后直接打印到控制台。这种方式最大的好处是零依赖、能直观检查每个点落位是否正确。等字符画版本的输出准确了,再接图形界面可视化,排错时思路会清晰很多。

3.2 控制台字符画验证版

控制台版本里,先定义一块画布:

#define WIDTH 64 #define HEIGHT 32 char canvas[HEIGHT][WIDTH + 1]; void clearCanvas() { for (int y = 0; y < HEIGHT; ++y) { for (int x = 0; x < WIDTH; ++x) { canvas[y][x] = '.'; } canvas[y][WIDTH] = '\0'; } } void setPixel(int x, int y) { if (x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT) { canvas[y][x] = '*'; } } void printCanvas() { for (int y = 0; y < HEIGHT; ++y) { puts(canvas[y]); } }

这里有一个容易忽略的点:数组的canvas[y][x]中第一个下标是行数,对应屏幕的y坐标;第二个下标是列数,对应屏幕的x坐标。也就是说,打印时默认y轴向下。如果你习惯了数学坐标系里y轴朝上,打印字符画时经常会把起点和终点搞反。解决方法是把画布按倒序打印:从最后一到第一行逐行输出,这样视觉上和数学坐标系就一致了。

在字符画模式下对比三种算法,你会很直观地看到:斜率比较平的直线,DDA和Bresenham落点大体一致,但在某些局部位置,浮点取整的误差会让DDA的像素点出现轻微跳变;斜率接近1的直线,三种算法差异最小,因为每个整数x基本都对应一个整数y,选择空间不大;斜率很大接近90度的直线,主步进方向会从x轴变成y轴,如果代码没做方向判断,就会立刻出现断线。

我当时为了检查边界情况,专门测试了水平线(0,0)到(63,0)、垂直线(0,0)到(0,31)、对角线(0,0)到(31,31),以及一条斜率约0.7的普通斜线。每组都用三种算法各跑一遍,肉眼对比字符画,很多隐藏bug就是在这个环节被揪出来的。

3.3 OpenGL可视化版本

字符画版本验证的是像素落位逻辑,可视化版本用来验证最终视觉效果。使用OpenGL时,绘制像素的基本方法是把直线算法生成的点集交给glBegin(GL_POINTS)和glVertex2i来渲染:

void DrawLineByBresenham(int x0, int y0, int x1, int y1) { 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; glBegin(GL_POINTS); glColor3f(1.0f, 1.0f, 1.0f); while (true) { glVertex2i(x0, y0); if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } glEnd(); }

窗口初始化时要注意设置合适的投影矩阵。如果你希望屏幕坐标的y轴向上,用glOrtho(0, width, 0, height, -1, 1);如果希望y轴向下,和字符画的逻辑保持一致,就用glOrtho(0, width, height, 0, -1, 1)。实验时我强烈建议把两种坐标方式都试一遍,因为很多算法推导里用的是数学坐标,而屏幕底层不少系统是y向下的,搞混了直线的方向,最容易出现“为什么画出来跟预想上下颠倒”的困惑。

绘制多条线做对比时,建议给不同算法设置不同颜色,不然三条线叠在一起根本分不清哪个点是哪个算法产生的。也可以用glPointSize(3.0f)把点尺寸调大,让生成的点阵在屏幕上更明显;但对比完毕、准备看整体效果时,要记得把点尺寸调回1或2,否则线会显得过粗,反而掩盖了算法的真实表现。

3.4 三种算法的运行效果对比

为了让结果更有说服力,我做了两组试验。第一组绘制从(0,0)到(50,15)的直线,斜率约0.3。三种算法最终生成的像素点差别很小,主要差异出现在线的某些局部位置:DDA因为浮点四舍五入,偶尔会把y值略微抬高或降低,视觉上会多出一个小毛刺;Bresenham和中点画线法的整数误差决策则稳定得多,整条线的像素分布更均匀。

第二组绘制从(20,5)到(5,30)的直线,斜率绝对值大于1且方向为负。这一组如果代码没有处理好“主步进方向”和“坐标正负方向”,很容易出现断点。我一开始就只写了第一象限的情况,拿到这组测试数据后,线条直接从中间断成了两截。改成统一方向判断之后,问题立刻消失。

可视化之后,如果你想把算法放到更接近真实渲染的环境里去评价,可以把同一组随机端点线循环绘制一万次,用计时工具分别记录DDA、中点画线法、Bresenham的总耗时。结果通常是DDA明显慢一些,另外两种相差不大,而且直线越长、端点坐标越大,DDA的劣势越明显。原因就是浮点累加和每步取整的开销高,误差还会随距离线性增长。

4. 实验中的常见问题与调试实录

4.1 斜率主步进方向选错导致断线

先说断线问题。很多人画直线时,代码只写了 |k|≤1 的情况,x每次加1、y按斜率累加。一旦遇到斜率大于1或小于-1的直线,画面就会出现稀疏的点堆或断线。根因在于主步进方向选错了。斜率为2的直线,如果仍然以x为主步进方向,x每次加1、y每次加2,实际仿佛每走两步才出现一个新点,而中间那些位置全是空洞。

解决方法是:比较dx和dy的绝对值,哪个大就以哪个坐标为主步进方向。这个判断在DDA代码里体现为steps = max(|dx|, |dy|),在Bresenham统一版本里则体现在err = dx - dy以及两个if条件的组合上。如果你自己实现中点画线法,别忘了在进入循环前把斜率绝对值归一化到0到1之间的处理分支,否则同样会出现断线。

4.2 浮点误差累积让终点悬空

DDA画一条几百像素的长线后,终点经常没有落在预期的(x1, y1)上,视觉上终点附近有几个像素的空隙或者多个像素堆积。原因是浮点数加法的误差在小数位上不断累积,最后一次取整结果自然就可能偏离整点坐标。

排查方式不复杂:在循环结束时,强制调用一次setPixel(x1, y1),先保证终点的正确性。但这只是亡羊补牢,如果想要从根本上改善,可以改用整数运算算法,或者使用定点数思路让浮点误差不累积。以我自己的经验,实验结果里DDA确实会在长线上出现末端偏离,报告里把这个现象专门写出来,反而能体现你对算法差异的理解。

4.3 断线、重影和锯齿现象的真正原因

实验中出现的问题很多,这里整理一份常见的现象对照,方便你快速排查。

现象可能原因解决方法
画出的线段断成点堆主步进方向选择错误,斜率大于1时仍按x方向推进以dx、dy中绝对值较大者为步进方向
线终点悬空或错位DDA浮点误差累积,取整不可靠终点强制绘制;或改用Bresenham
线歪斜、有毛刺取整数使用了截断而不是四舍五入使用floor(x+0.5f)风格的四舍五入
整个图形上下颠倒屏幕坐标y轴方向和数学坐标不一致检查投影矩阵设置,或打印前倒序输出画布
线条重影或粗细不一同一位置重复绘制;或误用了线段绘制函数检查循环边界;确认用GL_POINTS而不是GL_LINES
只有部分方向能画,其他方向失败算法只处理了第一象限情形使用带sx、sy方向标志的统一版本

锯齿是像素网格的固有特性,任何直线算法都无法完全消除,只能尽量让像素点列更均匀。Bresenham和中点画线法在理论上的选择是最优的。如果你还需要视觉上更光滑的线,可以进一步研究抗锯齿技术,比如把像素亮度按照覆盖面积分配,超采样也是一种思路。不过这次实验的核心是理解基础生成逻辑,抗锯齿可以先放一放。

另外还有一个小坑:设置OpenGL渲染方式时,用了glBegin(GL_LINES)而不是GL_POINTS,会导致直线算法给出的点被当成线段端点自动连接,本来点阵形式的模拟被二次处理,结果和理想直线产生不小的误差。此坑我踩过一次,提醒各位注意。

4.4 实验报告和代码里值得写入的结论

实验做完,报告别只贴三份代码就交差。我建议把三种算法在相同测试条件下的像素落点图截出来,用文字说明差异和适用场景。可以写这样一段结论:DDA适合教学演示,因为思路最直观;中点画线法用判别式避免浮点运算,是向高效整数算法过渡的一环;Bresenham以误差项为核心,用纯整数运算完成逐点决策,是实际软件渲染中最常见的直线光栅化方案之一。在常见的第一象限平缓斜率情况下,中点画线法和Bresenham决策一致,两者的表达差异提醒我们:看算法不能只看公式,还要看它解决的问题和约束条件。

再说一个普遍存在的问题:很多初学同学把三种算法当成三个必须背下来的独立模板,每个都按不同方式处理方向对称性,最后debug起来极其痛苦。我的建议是,方向处理单独抽出来写公共代码,统一计算步进方向和坐标正负,算法内部只负责主循环的像素决策。这样三种算法设置同一个测试入口,输入和输出保持一致,对比起来会清爽得多。

我个人在实际操作中的体会是,做完这个实验后最大的变化,是以后看到任何“画线”相关的图形代码,我都会下意识地想一想它用了什么算法、有没有处理方向边界、会不会因为浮点误差导致长线漂移。下次面试或复习时,画一遍(0,0)到(5,3)的Bresenham手推过程,比背任何代码都管用,这个算法就真正属于你了。

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

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

立即咨询