推荐关注公众号「卤蛋实验室」或访问博客原文,更新更及时,阅读体验更佳

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

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

1.DDA 直线算法

1.1 简单实现

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

其中斜率是 ,直线在 轴上的截距是 。

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

这时候用两点式表示一根直线是最方便的,其中 ,:

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

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

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

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);
    } 
}

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

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

1.2 优化

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

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

比如说 从 变化到 时,每次绘制时, 都是按步长 1 增长的,也就是 。

这时候 。

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

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;
  }
}

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

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

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

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

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

2.Bresenham’s 直线算法

2.1 初步实现

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

最一开始,我们先考虑所有直线里的一个子集,即斜率范围在 之间的直线:。

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

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

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

  • 先把 这个值带入直线方程里,算出来 的值
  • 然后比较 和 的大小

    • ,选点
    • ,选点

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


day2_Bresenham_line

如上图所示,实际上绘制的点的位置是 ,理论上点位置是 。

当点从 移动到 时,理论上新点的位置应该是 ,其中 k 是直线的斜率。

实际绘制时,要比较 和 的大小:

  • ,选点
  • ,选点

对于下一个新点 ,我们可以按照下式更新误差 :

  • 若前一个点选择的是 ,则
  • 若前一个点选择的是 ,则

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

2.2 消除浮点运算

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

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

然后用 表示 ,上式可以转换为

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

等号两边同时乘以 ,有:

然后用 表示 ,可以得到:

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

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;
    }
  }
}

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

3.绘制模型

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

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

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

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

上诉流程的前三步已经被原作者封装好了,我们直接把源码里的 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

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

参考连接:

Line Drawing on Raster Displays

The Bresenham Line-Drawing Algorithm

DDA Line Drawing Algorithm - Computer Graphics

Bresenham's Line Drawing Algorithm


欢迎大家关注我的微信公众号:卤蛋实验室,目前专注前端技术,对图形学也有一些微小研究。

也可以加我的微信 egg_labs,欢迎大家来撩。

【十天自制软渲染器】DAY 02:画一条直线(DDA 算法 & Bresenham’s 算法)的更多相关文章

  1. 【十天自制软渲染器】DAY 01:图形学学习建议与环境搭建

    推荐直接阅读博客原文,更新更及时,阅读体验更佳 「十天自制软渲染器」这个标题我承认标题党了.在对图形学一无所知的情况下想十天自制一个软渲染器,就好似一节课没上过却试图一个晚上看完<30 天精通 ...

  2. 【十天自制软渲染器】DAY 03:画一个三角形(向量叉乘算法 & 重心坐标算法)

    如果你喜欢我写的文章,可以把我的公众号设为星标 ,这样每次有更新就可以及时推送给你啦. 前面两天画了点和线,今天我们来画一个最简单也是最强大的面--三角形. 本文主要讲解三角形绘制算法的推导和思路(只 ...

  3. 用 windows GDI 实现软光栅化渲染器--gdi3d(开源)

    尝试用windows GDI实现了一个简单的软光栅化渲染器,把OpenGL渲染管线实现了一遍,还是挺有收获的,搞清了以前一些似是而非的疑惑. ----更新2015-10-16代码已上传.gihub地址 ...

  4. Restful framework【第十篇】响应器(渲染器)

    基本使用 -响应器(一般用默认就可以了) -局部配置 renderer_classes=[JSONRenderer,] -全局配置 'DEFAULT_RENDERER_CLASSES': ( 'res ...

  5. CSharpGL(34)以从零编写一个KleinBottle渲染器为例学习如何使用CSharpGL

    CSharpGL(34)以从零编写一个KleinBottle渲染器为例学习如何使用CSharpGL +BIT祝威+悄悄在此留下版了个权的信息说: 开始 本文用step by step的方式,讲述如何使 ...

  6. three.js 第二篇:场景 相机 渲染器 物体之间的关系

    w我用画画来形容他们之间的关系 场景就是纸张 相机就是我们的眼睛 物体就是在我们脑海中构思的那个画面 渲染器就是绘画这个动作 场景(Scene): 初始化:var scene = new THREE. ...

  7. Qt 3D的研究(十):描边渲染(轮廓渲染)以及Silhouette Shader

    Qt 3D的研究(十):描边渲染(轮廓渲染)以及Silhouette Shader 之前写了两篇文章,介绍了我在边缘检測上面的研究.实际上.使用GPU对渲染图像进行边缘检測.前提是须要进行两遍渲染.前 ...

  8. 基于显卡的光栅化渲染器Gaius计划

    决定实现一个基于显卡的光栅化渲染器,能将一些基于显卡的新算法融入其中.

  9. 基于物理渲染的渲染器Tiberius计划

    既然决定实现一个光栅化软件渲染器,我又萌生了一个念头:实现一个基于物理渲染的渲染器.

随机推荐

  1. JavaSE02-基本语法

    1.注释 注释是对代码的解释和说明文字,可以提高程序的可读性,因此在程序中添加必要的注释文字十分重要. Java中的注释分为三种: 单行注释.单行注释的格式是使用//,从//开始至本行结尾的文字将作为 ...

  2. Servlet中的装饰者模式

    装饰者模式 Decorator模式或者Wrapper模式允许修饰或者封装(在字面意义中,即修改行为)一个对象,即使你没有该对象的源代码或者该对象标识为final. Decorator模式适用于无法继承 ...

  3. css进阶 05-CSS的一些小知识

    05-CSS的一些小知识 #隐藏盒子的几种方式 隐藏盒子,有以下几种方式: (1)方式一: overflow:hidden; //隐藏盒子超出的部分   (2)方式二: display: none; ...

  4. DRF终极封装ViewSet和Router附教程PDF源码

    在DRF官方教程的学习过程中,一个很明显的感受是框架在不断地进行封装,我们自己写框架/工具/脚本/平台也可以模仿模仿,先完成底层代码,再做多层封装,让使用者很容易就上手操作.本文是教程的最后一篇,介绍 ...

  5. Neighbour-Joining (NJ算法)

    clc;clear all;close all; Distance = [0,2,4,6,6,8; 2,0,4,6,6,8; 4,4,0,6,6,8; 6,6,6,0,4,8; 6,6,6,4,0,8 ...

  6. MGRE实验--内网为OSPF

    实验要求: R2为ISP,只能配置IP地址 R1.R3.R4之间构建MGRE环境,仅R1IP地址固定 内网使用OSPF获取路由,全网可达 拓扑: 1.配置IP地址 2.配置缺省 3.配置MGRE环境 ...

  7. (七)、touch--创建文件或者更新时间戳

    一.命令说明与格式 创建文件并更新时间戳,若要创建的文件名已经存在,则仅仅更新时间戳,而不改变其他任何信息 格式:touch     [选项]   目录名/文件名 选项: -a            ...

  8. (六)、mkdir--创建目录make directory

    一.命令详解与命令格式 在文件系统中创建新的目录, 格式:mkdir   [-选项]   目录名 目录名既可以是相对路径名,也可是相对路径名 选项: -p或者--parent,创建指定路径中所有不存在 ...

  9. 短链接服务Octopus的实现与源码开放

    前提 半年前(2020-06)左右,疫情触底反弹,公司的业务量不断提升,运营部门为了方便短信.模板消息推送等渠道的投放,提出了一个把长链接压缩为短链接的功能需求.当时为了快速推广,使用了一些比较知名的 ...

  10. Weblogic命令执行漏洞(CVE-2018-2628)复现

    一.漏洞环境搭建 CVE-2018-2628影响的软件版本为: Weblogic 10.3.6.0 Weblogic 12.1.3.0 Weblogic 12.2.1.2 Weblogic 12.2. ...