【十天自制软渲染器】DAY 02:画一条直线(DDA 算法 & Bresenham’s 算法)

推荐直接阅读博客原文,更新更及时,阅读体验更佳


写文不易,恳求各位观众老爷 点赞 👍,收藏 📁,评论 💬 三连支持一下!!!谢谢你,这对我真的很重要!


第一天我们搭建了 C++ 的运行环境并画了一个点,根据 点 → 线 → 面 的顺序,今天我们讲讲如何画一条直线。

本文主要讲解直线绘制算法的推导和思路(莫担心,只涉及到一点点的中学数学知识),最后会给出代码实现,大家放心的看下去就好。

1.DDA 直线算法

1.1 简单实现

我们先来回顾一下中学的几何知识,如何在二维平面内表示一条直线?最常见的就是斜截式了:

y = kx + b

其中斜率是 k,直线在 y 轴上的截距是 b

<br />

斜截式在数学上是没啥问题的,但是在实际的工程项目中,因为硬件资源是有限的,我们不可能也没必要表示一条无限长度的直线,现实往往是已知一条线段的起点 (x_1, y_1)终点 (x_2, y_2),然后把它画出来。

这时候用两点式表示一根直线是最方便的,其中 x_1 \leq x \leq x_2y_1 \leq y \leq y_2

\frac{x-x_{1}}{x_{2}-x_{1}}=\frac{y-y_{1}}{y_{2}-y_{1}}

把上面的式子稍作变形,可以把 xy 用参数 \lambda 表示:

\left.\begin{array}{l}x=\lambda\left(x_{2}-x_{1}\right)+x_{1} \\ y=\lambda\left(y_{2}-y_{1}\right)+y_{1}\end{array}\right\} 0 \leq \lambda \leq 1

这时候我们只要取不同的 \lambda,就可以得出对应的 x 和 y。

<br />

按照以上的思路,我们可以用代码实现一下。C++ 的实现也很简单,如下所示(dl 表示 d \lambda):

void line(
  int x1, int y1, 
  int x2, int y2, 
  TGAImage &image, TGAColor color) { 
    const float dl = 0.01;
    int dx = x2 - x1;
    int dy = y2 - y1;
    for (float t=0.0; t<1.0; t+=dl) { 
        int x = x1 + dx * t;
        int y = y1 + dy * t;
        image.set(x, y, color);
    } 
}

这个是直线算法的初步实现,只能说「能用」,地位和排序算法里的「冒泡排序」一样,目的达到了,但是性能不太好:

  • 每画一个点,都要运行两次乘法
  • 大量使用浮点运算(众所周知,v_{float} < v_{fixed} < v_{int}
  • 如果 dl 取的比较小,会导致一个像素点会被绘制多次,重复计算
  • 如果 dl 取的比较大,会导致直线断掉

1.2 优化

下面我们就一步一步优化上面的算法。

首先我们注意到,对于屏幕绘制直线这个场景,理论上是连续的,但实际是离散的

比如说 xx_1 变化到 x_2 时,每次绘制时,x 都是按步长 1 增长的,也就是 x_{new} = x_{old} + 1

这时候 y_{new} = y_{old} + \frac{y_2 - y_1}{x_{2}-x_{1}} = y_{old} + k

我们把上面的公式写成代码,就是下面这个样子:

void line(
  int x1, int y1, 
  int x2, int y2, 
  TGAImage &image, TGAColor color) { 
  float x = x1;
  float y = y1;
  float step = std::abs(x2 - x1);
  float dlx = (x2 - x1) / step;
  float dly = (y2 - y1) / step;
  
  for (int i=1; i<step; i++) { 
    image.set(x, y, color);
    x = x + dlx;
    y = y + dlx;
  } 
}

这个算法其实还有一点儿问题,就是绘制斜率大于 1 的直线时,绘制出的直线会断掉。比如说从 (0, 0) 点绘制到 (2, 4) 点,按照上面的算法只会绘制两个点,但是我们期望的是右图那样,起码各个像素要连接起来:

不连续的线 vs 连续的线

解决方法也很简单,绘制这种比较「陡峭」的直线时(斜率绝对值大于 1),以 y 的变化为基准,而不是以 x,这样就可以避免上面直线不连续情况。

最后的直线算法就是这样:

void line(
  int x1, int y1, 
  int x2, int y2, 
  TGAImage &image, TGAColor color) { 
  float x = x1;
  float y = y1;
  int dx = x2 - x1;
  int dy = y2 - y1;
  float step;
  float dlx, dly;

  // 根据 dx 和 dy 的长度决定基准
  if (std::abs(dx) >= std::abs(dy)) {
    step = std::abs(dx);
  } else {
    step = std::abs(dy);
  }

  dlx = dx / step;
  dly = dy / step;

  for (int i=1; i<step; i++) {
    image.set(x, y, color);
    x = x + dlx;
    y = y + dly;
  }
}

然后我们用这个算法测试一下不同起点不同斜率的直线,看效果运行良好:

image

<br />

这个算法就是经典的 DDA (Digital differential analyzer) 算法,他比我们一开始的代码要高效的多:

  • 消除了循环内的乘法运算
  • 避免了重复的绘制运算
  • 保证线段连续不会断掉

但是它还有个很耗性能的问题:计算过程中涉及大量的浮点运算

作为渲染器最底层的算法,我们肯定希望是越快越好。下面我们就来学习一下,消除浮点运算的 Bresenham’s 直线算法。

2.Bresenham’s 直线算法

2.1 初步实现

本节内容不会从一开始就讲完善版的 Bresenham’s 算法,我们先从一个小节开始推导,最后推导出完善的算法。

最一开始,我们先考虑所有直线里的一个子集,即斜率范围在 [0, 1] 之间的直线:0 \leq k \leq 1

上一小节里我们说过,对于屏幕绘制直线这个场景,理论上是连续的,但实际是离散的。我们先假设已经绘制了一个点 (x,\ y),那么在像素屏幕上,下一个新点的位置,只可能有两种情况:

  • (x + 1,\ y)
  • (x + 1,\ y + 1)

那么问题就转化为,下一个新点的位置该如何选择?

我想大家应该都想到方案了,大体思路如下

  • 先把 x_{new} = x + 1 这个值带入直线方程里,算出来 y_{new} 的值
  • 然后比较 y_{new}y + 0.5 的大小
    • y_{new} \leq y + 0.5,选点 (x + 1,\ y)
    • y_{new} > y + 0.5,选点 (x + 1,\ y + 1)

我们再把思路完善一下,把每次取舍时的误差考虑进去:

day2_Bresenham_line

如上图所示,实际上绘制的点的位置是 (x, y),理论上点位置是 (x,\ y + \epsilon)

当点从 x 移动到 x + 1 时,理论上新点的位置应该是 (x + 1,\ y + \epsilon + k),其中 k 是直线的斜率。

实际绘制时,要比较 y + \epsilon + ky + 0.5 的大小:

  • y + \epsilon + k \leq y + 0.5,选点 (x + 1,\ y)
  • y + \epsilon + k > y + 0.5,选点 (x + 1,\ y + 1)

对于下一个新点 x + 2,我们可以按照下式更新误差 \epsilon

  • 若前一个点选择的是 (x + 1,\ y),则 \epsilon_{new} = (y + \epsilon + k) - y = \epsilon + k
  • 若前一个点选择的是 (x + 1,\ y + 1),则 \epsilon_{new} = (y + \epsilon + k) - (y + 1) = \epsilon + k - 1

把上面的思考过程用伪代码表示一下:

\begin{aligned} & \epsilon \leftarrow 0, \quad y \leftarrow y_{1} \\ & \pmb{for} \ x \leftarrow x_{1} \ \pmb{to} \ x_{2} \ \pmb{do} \\ & \quad \pmb{draw} \ \pmb{point} \ \pmb{at} \ (x, \ y) \\ & \quad \pmb{if} \ ( \ (\epsilon + k) < 0.5) \\ & \quad \quad \epsilon \leftarrow \epsilon + k \\ & \quad \pmb{else} \\ & \quad \quad y \leftarrow y + 1 \\ & \quad \quad \epsilon \leftarrow \epsilon + k - 1 \\ & \quad \pmb{end} \ \pmb{if} \\ & \pmb{end} \ \pmb{for} \end{aligned}

2.2 消除浮点运算

观察上面的伪代码,我们可以发现这里面出现了 0.5,也就是说存在浮点运算。下面我们就通过一些等价的数学变换消除浮点数。

首先对于不等式 \epsilon + k < 0.5,我们给它不等号左右两边同时乘以 2 倍的 \Delta x,这样就可以同时消除斜率除法和常量 0.5 带来的浮点运算:

\epsilon + \Delta y / \Delta x < 0.5

2 \epsilon \Delta x + 2 \Delta y <\Delta x

然后用 \epsilon^{\prime} 表示 \epsilon\Delta x,上式可以转换为 2(\epsilon^{\prime} + \Delta y)< \Delta x

同样的,我们在更新 \epsilon 时,把它也替换为 \epsilon^{\prime} ,也就是对于下面两式:

  • \epsilon = \epsilon + m

  • \epsilon = \epsilon + m - 1

等号两边同时乘以 \Delta x,有:

  • \epsilon \Delta x = \epsilon \Delta x+\Delta y

  • \epsilon \Delta x = \epsilon \Delta x+\Delta y-\Delta x

然后用 \epsilon^{\prime} 表示 \epsilon\Delta x,可以得到:

  • \epsilon^{\prime} = \epsilon^{\prime}+\Delta y

  • \epsilon^{\prime} = \epsilon^{\prime}+\Delta y-\Delta x

这时候我们就可以得到一个去掉浮点数运算的伪代码:

\begin{aligned} & \epsilon^{\prime} \leftarrow 0, \quad y \leftarrow y_{1} \\ & \pmb{for} \ x \leftarrow x_{1} \ \pmb{to} \ x_{2} \ \pmb{do} \\ & \quad \pmb{draw} \ \pmb{point} \ \pmb{at} \ (x, \ y) \\ & \quad \pmb{if} \ ( \ 2 (\epsilon^{\prime} + \Delta y) < \Delta x) \\ & \quad \quad \epsilon^{\prime} \leftarrow \epsilon^{\prime} + \Delta y \\ & \quad \pmb{else} \\ & \quad \quad y \leftarrow y + 1 \\ & \quad \quad \epsilon^{\prime} \leftarrow \epsilon^{\prime} + \Delta y - \Delta x \\ & \quad \pmb{end} \ \pmb{if} \\ & \pmb{end} \ \pmb{for} \end{aligned}

<br />

C++ 实现如下:

void line(Screen &s,
  int x1, int y1,
  int x2, int y2,
  TGAImage &image, TGAColor color) {
  int y = y1;
  int eps = 0;
  int dx = x2 - x1;
  int dy = y2 - y1;

  for (int x = x1; x <= x2; x++) {
    image.set(x, y, color);
    eps += dy;
    // 这里用位运算 <<1 代替 *2
    if((eps << 1) >= dx)  {
      y++;
      eps -= dx;
    }
  }
}

这样我们就实现了斜率在 [0, 1] 区间的高效算法。也就是说,现在我们可以绘制 1/8 个象限的直线了。剩下范围的直线,可以通过交换 xy 等方式实现绘制。具体的实现都是些脏活累活,就不摆出来了,感兴趣的可以去 GitHub 上看代码的完整实现

<br />

image

3.绘制模型

这一部分可以结合原英文教程学习,我只做一些细节上的补充。

前面两个小节都是算法基础学习,本小节开始加载一个非洲人的 .obj 模型,然后把模型上每个三角形面的点连接起来。

OBJ 文件是一种被广泛使用的 3D 模型文件格式(obj 为后缀名),用来描述一个三维模型。模型关键字较为繁琐,限于篇幅本文暂不展开,大家可以自行搜索学习。

这一节的流程也很清楚:从磁盘上加载 .obj 文件 → 按行分析 .obj 文件 → 构建 model → 循环 model 中的每个三角形 → 连接三角形的三条边 → 渲染出图

<br />

上诉流程的前三步已经被原作者封装好了,我们直接把源码里的 model.hmodel.cpp 拖到主工程里就可以了,感兴趣的人可以看一下源码实现,非常简单,在一个 while 循环里一直 readline 就可以了,因为和图形学关系不大,我这里就略过了。

最后的画三角形的代码如下,关键步骤我已经用注释标注了:

// 实例化模型
model = new Model("obj/african_head.obj");

// 循环模型里的所有三角形
for (int i = 0; i < model->nfaces(); i++) {
  std::vector<int> face = model->face(i);

  // 循环三角形三个顶点,每两个顶点连一条线
  for (int j = 0; j < 3; j++) {
    Vec3f v0 = model->vert(face[j]);
    Vec3f v1 = model->vert(face[(j + 1) % 3]);
    
    // 因为模型空间取值范围是 [-1, 1]^3,我们要把模型坐标平移到屏幕坐标中
    // 下面 (point + 1) * width(height) / 2 的操作学名为视口变换(Viewport Transformation)
    int x0 = (v0.x + 1.) * width / 2.;
    int y0 = (v0.y + 1.) * height / 2.;
    int x1 = (v1.x + 1.) * width / 2.;
    int y1 = (v1.y + 1.) * height / 2.;
    
    // 画线
    line(x0, y0, x1, y1, image, white);
  }
}

最后渲染出的图像如下:

toyrenderer_day02_obj

<br />

<br />

今天学习了如何画一条线,明天我们学习如何画一个三角形


写文不易,恳求各位观众老爷 点赞 👍,收藏 📁,评论 💬 三连支持一下!!!谢谢你,这对我真的很重要!


参考连接:

Line Drawing on Raster Displays

The Bresenham Line-Drawing Algorithm

DDA Line Drawing Algorithm - Computer Graphics

Bresenham's Line Drawing Algorithm


©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,137评论 6 511
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,824评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,465评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,131评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,140评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,895评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,535评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,435评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,952评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,081评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,210评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,896评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,552评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,089评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,198评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,531评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,209评论 2 357

推荐阅读更多精彩内容