数值微分DDA算法与Bresenham算法画直线
前提
最近在学计算机图形学画线算法,主要学了两种
- 数值微分算法(Digital Differential Analyzer, DDA)
- 中点Bresenham算法
直线
我们既然要画线就必然要研究一些直线的基本性质。
在中学就学过直线的斜截式方程为(我们先不考虑斜率不存在的情况,对与这种情况我们非常容易处理,只需单独拿出来判断下就行)
$$ y=kx+b $$
其中k为直线斜率,b为直线截距。在计算机中,我们如果要画一条线段,只需要取直线上的部分的点即可。我们不妨考虑线段的起始点和终点,其中起点坐标为:
$$ (x_0,y_0) $$
终点坐标为:
$$ (x_{end},y_{end}) $$
我们很轻松能得到斜率和截距与两点的关系式:
$$ k = \frac{y_{end}-y_{0}}{x_{end}-x_{0s}} $$
$$ b = y_0 - kx_0 $$
不妨分别给定x方向和y方向的增量$\Delta{x}$和$\Delta{y}$
$$ \Delta{y}=k\Delta{x} $$
变形可得
$$ k= \frac{\Delta{y}}{\Delta{x}} $$
在光栅算法中,我们规定x和y方向上变化步长(变化量的绝对值)大的那一方向为最大位移方向。(沿正还是负根据实际情况判断)
DDA算法
原理
取无穷小量$\varepsilon >0$,由于直线的一阶导数是连续的,而且对于$\Delta{x}$和$\Delta{y}$是成比例的,因此可通过在当前位置$(x_i,y_i)$,分别加上$\varepsilon x$和$\varepsilon y$来求出下一点的坐标$(x_{i+1},y_{i+1})$,即有方程
$$ \begin{cases} x_{i+1} = x_{i}+\varepsilon \Delta x \\ y_{i+1} = y_{i}+\varepsilon \Delta y \\ \end{cases} $$
对于计算机来讲这些点都是离散的,也就是说我们不可能取真正意义上的无穷小量。对于屏幕来讲,最小的单位就是一个像素,也就是说对于x和y方向上我们只能移动正整数个像素或者不位移,而最小的正整数为1,我们要绘制下一个点的坐标必然要在某个方向上移动1。
通俗一点来讲,我们取x、y其中一个方向移动一个像素,以x为例:我们在x轴上移动一个像素大小,那么根据直线方程$x_i+1$代入得到的y值很可能是个小数,我们需要通过向上取整或者向下取整来找一个与这个小数近似的整数来作为近似的y坐标。接下来就很简单了,我们仅需要计算一下恰好比这个小数大的整数和恰好比这个小数小的整数离该小数的距离即可(四舍五入,哪个整数离得近就选哪个整数)。
那么问题来了,我们究竟是选择x方向移动1,还是y方向移动1?
回到我们的实际问题上来:我们仅想要一些近似于实际直线上的点,越近似越好!
如果x变化比y快,也就是说x为最大位移方向,当我们选择移动y一个单位,x变化就可能非常大,带来的误差就可能大。y为最大位移方向,选择移动x同理。那么结论就有了:为降低误差,选最大位移方向为移动方向,移动1个单位的问题就转变成了找最大位移方向问题。
当x为最大位移方向的时候
$$ max(|\Delta x|,|\Delta y|) = |\Delta x| $$
也就是说
$$ 0 \le |k|\le 1 \\ \varepsilon |\Delta x| = 1 $$
我们可以得出
$$ \varepsilon |\Delta y| =\frac{|\Delta y|}{|\Delta x|} $$
那么代入方程组可得
$$ \begin{cases} x_{i+1} = x_{i}+\varepsilon |\Delta x| = x_{i}+\frac{\Delta x}{|\Delta x|}=x_i \pm 1\\ y_{i+1} = y_{i} + \varepsilon |\Delta y| = y_{i}+\frac{\Delta y}{|\Delta x|} = y_{i} \pm k \end{cases} $$
同理我们得出y为最大方向的时候方程组如下
$$ \begin{cases} x_{i+1} = x_{i} \pm \frac{1}{k} \\ y_{i+1} = y_{i} \pm 1 \end{cases} $$
对得到的$(x_{i+1},y_{i+1})$的坐标四舍五入即为我们要绘制的近似点的坐标。
上述两个方程组正负号处取正号还是负号与$\Delta x$和$\Delta y$有关系,例如对于初始点x坐标比终点x坐标小,斜率在$(0,1]$上的直线,最大位移方向为x方向,从起点到终点绘制x是不断增大的,所以$x_i \pm 1$应该为$x_i + 1$;结合斜率可知y也是增大的,则$y_i \pm k$应该变为$y_i + k$。
不妨将起点和终点坐标交换一下,此时$\Delta x$和$\Delta y$都为负,那么就变成了$x_i - 1$和$y_i - k$。
代码
C++风格的伪代码如下:
/// @brief DDA画直线
/// @param start 起始点
/// @param end 终点
void dda_line(Point& start,Point& end)
{
int dx = end.x-start.x;
int dy = end.y - start.y;
int steps = abs(dx)>abs(dy)?abs(dx):abs(dy); //迭代次数
double x_incre = dx/(double)steps; //x每次变化量
double y_incre = dy/(double)steps; //y每次变化量
double x=start.x,y=start.y;
//绘制像素,round为四舍五入函数
set_pixel(round(x),round(y),rgb(0,0,255));
for(int i=0;i<steps;i++){
x+=x_incre;
y+=y_incre;
set_pixel(round(x),round(y),rgb(0,0,255));
}
}
特点
DDA算法简单直观,容易实现,但是计算机并不太擅长计算浮点数,每一步都得四舍五入取整消耗资源相对较大,不利于硬件实现。
中点Bresenham算法
原理
对于直线$y=kx+b$移项可得隐式方程
$$ y-kx-b=0 $$
不妨令二元函数$F(x,y)$有如下形式
$$ F(x,y)=y-kx-b $$
对于点$(x_n,y_n)$,当$F(x_n,y_n)=0$时,该点在直线上;当$F(x_n,y_n)<0$时,点在直线下方;当$F(x_n,y_n)>0$时,点在直线上方。
我们分斜率区间为$(-\infty ,-1],(-1,0],(0,1](1,+\infty )$四种情况讨论
事实上当斜率$k =\pm 1$的时候x与y方向移动速度是一样的,我们选择x为最大移动方向和选择y为最大移动方向是一样的,因此上方给出的这四个区间有些可以在这个地方取等号。另外,当$x=0$时,无论0被划分为$(-1,0]$还是$[0,1]$其最大位移方向都是x。
在接下来的分类讨论中可能会出现如$k\le-1$和$-1\le k \le0$两种情况都能取到-1之类的描述,对于0和1同理,这并不妨碍结论的严谨性,我们知道这一点即可。
在只考虑x沿正方向增长的条件下(要求该方向上起始点横坐标比终点横坐标小,如果大的话我们只调换下这两个点坐标让小的点作为起始点,大的点作为终点,再按照下方流程绘制就行了),我们来看$0\le k\le1$的情况:
此时x为最大位移方向,不妨设$(x_i,y_i)$为绘制过程中的某一点,则下一点有$(x_{i}+1,y_i)$和$(x_i+1,y_i + 1)$两种选择。
我们根据中点公式
$$ x_{mid} = \frac{x_{a}+x_{b}}{2} \\ y_{mid} = \frac{y_a+y_b}{2} $$
得到这两个可选择点的中点坐标为
$$ (x_i+1,y_i+0.5) $$
将其带回二元函数$F(x,y)$得到判别式
$$ d_i = F(x_i+1,y_i+0.5)=y_i + 0.5 - k(x_i + 1)-b=y_i-kx_i-b-0.5-k $$
当$d_i<0$的时候说明中点在直线下方,$(x_i + 1,y_i+1)$离直线较近,我们应该选它;当$d_i>0$当时候,说明中点在直线上方,此时我们应该选择$(x_i+1,y_i)$
然后我们再来看看绘制下一个点
此时我们如果是从$(x_i+1,y_i+1)$出发,则下一步可选择的两个点分别为$(x_i+2,y_i+1)$和$(x_i+2,y_i+2)$,然后求出中点
$$ (x_i+2,y_i+1.5) $$
代入公式可得
$$ d_{i+1} = F(x_i+2,y_i+1.5)=y_i-kx_i-b-2k-1.5=(y_i-kx_i-b+0.5-k)+1-k=d_i+1-k $$
即
$$ d_{i+1}=d_i+1-k $$
如果从$(x_i+1,y_i)$出发,则可选择两个点分别为$(x_i+2,y_i+1)$和$(x_i+2,y_i)$
中点坐标为
$$ (x_i+2,y_i+0.5) $$
那么判别式为
$$ d_{i+1} = F(x_i+2,y_i+0.5)=y_i+0.5-k(x_i+2)-b=d_i-k $$
由此可以得出$d_i$与$d_{i+1}$之间的关系递推式
$$ d_{i+1}=\begin{cases} d_i-k,& d_i\ge 0 \\ d_i-k+1,&d_i < 0 \end{cases} $$
对于递推公式的初始值$d_0$,将$(x_0+1,y_0+0.5)$代入可得
$$ d_0 = F(x_0+1,y_0+0.5)=(y_0-kx_0-b)+0.5-k=0+0.5-k=0.5-k $$
同理,我们可知$k\ge 1$的情况:
最大位移方向为y方向
$$ d_i = F(x_i+0.5,y_i+1)=y_i+1-k(x_i+0.5)-b \\ d_0 = F(x_0+0.5,y_0+1) = (y_i-kx_0-b)-0.5k+1=1-0.5k $$
有递推式
$$ d_{i+1} = \begin {cases} d_i-k+1,& d_i\ge 0 \\ d_i+1, & d_i<0 \end{cases} $$
再来看$-1\le k\le 0$的情况:
最大位移方向为x方向,但y为负增长
$$ d_i = F(x_i+1,y_i-0.5) = y_i-0.5-k(x_i+1)-b \\ d_0 = F(x_0+1,y_0-0.5) = (y_0-kx_0-b)-k-0.5 $$
递推式为
$$ d_{i+1} = \begin{cases} d_i -k, & d_i<0 \\ d_i -k -1, & d_i\ge0 \end{cases} $$
最后看看$k\le -1$的情况
此时y为最大位移方向,y为负增长
$$ d_i = F(x_i+0.5,y_i-1) = y_i-1-k(x_i+0.5)-b \\ d_0 = F(x_0+0.5,y_0-1) = (y_0-kx_0-b)-0.5k-1 = -1-0.5k $$
依然有递推式
$$ d_{i+1} = \begin{cases} d_i - k - 1, & d_i<0 \\ d_i - 1, & d_i\ge 0 \end{cases} $$
优化
在DDA中我们就提到过计算机不喜欢计算浮点数和乘除法,这会降低执行效率不利于硬件实现,但是我们在Bresenham算法中看到依然会有这些情况存在,所以需要对算法进行一定的优化。
替换原有的d
利用$2\Delta xd$替换原有的$d$
- 当$0\le k\le1$时
$$ d_0 = \Delta x - 2\Delta y \\ d_{i+1} = \begin{cases} d_i+2\Delta x - 2\Delta y, & d_i < 0 \\ d_i-2\Delta y , & d_i\ge 0 \end{cases} $$
- 当$k\ge 1$时
$$ d_0 = 2\Delta x -\Delta y \\ d_{i+1} = \begin{cases} d_i + 2\Delta x, & d_i<0 \\ d_i + 2\Delta x - 2\Delta y, & d_i\ge 0 \end{cases} $$
- 当$ -1\le k \le 0$时
$$ d_0 = -2\Delta x - 2\Delta y \\ d_{i+1} = \begin{cases} d_i - 2\Delta y, & d_i<0 \\ d_i - 2\Delta y - 2\Delta x, & d_i \ge 0 \end{cases} $$
- 当$k\le -1$时
$$ d_0 = -2\Delta x - \Delta y \\ d_{i+1} = \begin{cases} d_i - 2\Delta x - 2\Delta y,& d_i<0 \\ d_i - 2\Delta x, &d_i \ge 0 \end{cases} $$
这里注意一点,我们递推式里的$d_i$并没有写成$2\Delta x d$的形式,这是因为我们在$d_0$的时候已经乘进去,这里的$d_i$已经是变化后的了
代码
C++风格伪代码
/// @brief Breseham中点画线算法
/// @param start 起始点
/// @param end 终止点
void mid_bresenham_line(Point &start, Point &end)
{
// 如果起点横坐标大于终点,则交换两点(保证x正增长)
if (start.x > end.x)
{
int temp = start.x;
start.x = end.x;
end.x = temp;
temp = start.y;
start.y = end.y;
end.y = temp;
}
int x = start.x;
int y = start.y;
// 绘制图形
set_pixel(x, y, rgb(0, 0, 255));
int dx = end.x - start.x;
int dy = end.y = start.y;
int d; // 判别式
if (dy <= dx && dy >= 0)
{
x += 1;
d = dx - 2 * dy;
if (d < 0)
{
y += 1;
set_pixel(x, y, rgb(0, 0, 255));
d = d + 2 * dx - 2 * dy;
}
else
{
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dy;
}
}
else if (dy >= dx)
{
y += 1;
d = 2 * dx - dy;
if (d < 0)
{
x += 1;
set_pixel(x, y, rgb(0, 0, 255));
d = d + 2 * dx;
}
else
{
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dy - 2 * dx;
}
}
else if (dy >= -dx && dy < 0)
{
x += 1;
d = -2 * dx - 2 * dy;
if (d < 0)
{
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dy;
}
else
{
y -= 1;
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dy - 2 * dx;
}
}
else
{
y -= 1;
d = -2 * dx - dy;
if (d < 0)
{
x += 1;
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dx - 2 * dy;
}
else
{
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dx;
}
}
while (x < end.x)
{
if (dy <= dx && dy >= 0)
{
x += 1;
if (d < 0)
{
y += 1;
set_pixel(x, y, rgb(0, 0, 255));
d = d + 2 * dx - 2 * dy;
}
else
{
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dy;
}
}
else if (dy >= dx)
{
y += 1;
if (d < 0)
{
x += 1;
set_pixel(x, y, rgb(0, 0, 255));
d = d + 2 * dx;
}
else
{
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dy - 2 * dx;
}
}
else if (dy >= -dx && dy < 0)
{
x += 1;
if (d < 0)
{
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dy;
}
else
{
y -= 1;
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dy - 2 * dx;
}
}
else
{
y -= 1;
if (d < 0)
{
x += 1;
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dx - 2 * dy;
}
else
{
set_pixel(x, y, rgb(0, 0, 255));
d = d - 2 * dx;
}
}
}
}
改进的Bresenham算法
原理
在中点Bresenham中,我们是通过判断中点与直线位置关系来进行选择点位的。现在我们换一种思路,仅仅使用增量整数计算。
当斜率$0\le k\le 1$时算法步骤大致描述如下:
- 输入直线的两端点
- 计算初始值 $\Delta x$ 、 $\Delta y$ 、 $d=0$ 、 $x=x_0$ 、 $y = y_0$
- 绘制点 $(x,y)$
- $d$ 更新为 $d+k$,判断d的符号。若 $d>0.5$ ,则 $(x,y)$ 更新为 $(x+1,y+1)$,同时将 $d$ 更新为 $d-1$ ;否则 $(x,y)$ 更新为 $(x+1,y)$
- 当直线没有画完时,重复3、4两步
我们每次都要判断 $d>0.5$ ,为了方便计算,不妨令 $e = d-0.5$
对于该种情况下的关于 $x$ 和 $y$ 的递推公式如下
$$ \begin{cases} x_{i+1} = x_i+1 \\ y_{i+1} = \begin{cases} y_i+1,& e>0,\\ y_i,& e \le 0 \end{cases} \end{cases} $$
对于e有如下操作:
- $e_0 = -0.5$
- 每走一步在原有基础上加 $k$ ,即 $e_{i+1} = e_i+k$
- 若 $e_{i+1}>0$ , $e_{i+1} = e_{i+1}-1$
类似于中点Bresenham算法,我们在这里也要优化掉小数和除法运算:即用$2\Delta x e$来替换$e$
所以对于e就变成了:
- $e_0 = -\Delta x$
- 每走一步 $e_{i+1}=e_i + 2\Delta y$
- 若 $e_{i+1} > 0,e_{i+1}=e_{i+1}-2\Delta x$
此时算法步骤为:
- 输入直线两端点
- 计算初始值 $\Delta x$ 、 $\Delta y$ 、 $e = -\Delta x$ 、 $x=x_0$和$y = y_0$
- 绘制点 $(x,y)$
- $e$更新至$e+2\Delta y$ ,判断e符号。若 $e>0$ ,则 $(x,y)$ 更新为 $(x+1,y+1)$ ,同时将 $e$ 更新为 $e-2\Delta x$ ,否则为 $(x+1,y)$
- 重复步骤3、4,直至画完直线
注意上述只给出了斜率k大于等于0小于等于1点步骤,其他范围同理可得
代码
为了简洁,值给出 $0\le k \le 1$ 的流程(CPP风格伪代码)
/// @brief Bresenham画直线(此函数仅为斜率0<=k<=1)
/// @param start 起始点
/// @param end 终点
void bresenham_line(Point& start, Point& end){
int dx = fabs(end.x-start.x),dy=(end.y-start.y);
int double_dy = 2*dy;
int double_dy_mins_dx = 2*(dy-dx);
int double_dx = 2*dx;
int e = -dx;
int x,y;
//比较大小,判断哪个是起点(确保起点横坐标小于终点)
if(start.x>end.x){
x = end.x;
y = end.y;
end.x = start.x;
}else{
x = start.x;
y = start.y;
}
set_pixel(x,y,rgb(0,0,255));
while(x<end.x){
++x;
e+=double_dy;
if(e>0){
x++,y++;
e -= double_dx;
}
else{
++x;
}
set_pixel(x,y,rgb(0,0,255));
}
}
结束
代码虽然是伪代码,主要是因为我当前环境下没有set_pixel
这个函数的实现,事实上这个API应该是由系统提供的,在Windows下好像是有(不确定),但当前环境为MacOS所以就没尝试,直接以伪代码的形式给出了。(如果Win下有这个函数的话应该导入相应的库然后以正确的函数名代替set_pixel
就能运行)
另外在写改进的Bresenham算法那一部分的介绍时,发现再写斜率为其它三种情况的推导和代码有些太过于麻烦了,所以仅给出了一种情况的,其它情况同理可得。另外,对于终点Bresenham算法的代码部分用了多层if
嵌套,这实际上是个不好的习惯,应该将其逻辑拆分成好几个函数逐层细化,不过此处主要介绍原理,就没考虑这方面问题。
当时写代码时为了不让IDE弹出烦人的报错,我自己定义了点的类型Point
typedef struct Point{
int x;
int y;
} Point;
最后,DDA和Bresenham不仅仅适用于画直线,曲线也可以,之后会写一篇画椭圆或圆的文章。