前言
众所周知,模型是由很多个小三角面组成的,而三角面又是由三条边组成的,所以要想渲染模型,第一步是画直线。这一节要详细讲三个经典的直线算法。
在上一讲中我们讨论了如何用 xmake 来做跨平台的编译工作,我对 Windows 和 Mac 平台的编译做了区分:在 win 平台链接下载好的 lib,在 mac 平台使用 brew 来引入 SDL2 的依赖。然后 xmake 的作者 ruki 大佬指出可以直接用 xmake 的 libsdl 包,支持跨平台,可以用优美而一致的代码实现两个平台的配置。
所以文件 xmake.lua 可以改为:
add_rules("mode.debug", "mode.release")
set_languages("c++14")
-- SDL2
add_requires("libsdl")
if is_os("windows") then
-- Avoid for error LINK1561
add_ldflags("/SUBSYSTEM:CONSOLE")
end
target("DLSoftRenderer")
set_kind("binary")
add_includedirs("src/include")
add_files("src/*.cpp")
add_packages("libsdl")
然后我们就可以安心地把 SDL2 的文件夹从项目中移除了,完全交给 xmake 去处理。
概述
由于显示器精度总是有限的,所以在屏幕上只能用离散的点去逼近直线,如下图所示:
直线算法的目标就是以最少的计算量,绘制出最逼近指定直线路径上的最佳像素点。该篇博客主要讲述两种画直线的算法:DDA 算法和 Bresenham 算法。
DDA 算法
第一个要介绍的算法叫做 DDA (Digital Differential Analyzer,数值微分法),顾名思义,该方法是用微积分的思想来画直线。
直线最简单的表示方法为斜截式:
给定直线的两个端点 和 ,则微分形式可以表示为:
基本思想是沿着某一个轴递增,计算另一个轴的增量,根据斜率拆解成两种情况: 和 。
综合考虑两种情况,写成伪代码就是:
DrawLine(x0, y0, x1, y1)
dx = x1 - x0
dy = y1 - y0
step = max(abs(dx), abs(dy))
xIncre = dx / step
yIncre = dy / step
for i from 0 to step
SetPixel(round(x), round(y))
x += xIncre
y += yIncre
DDA 算法的每一步都是在上一步的值加上一个增量来获得的,故称为增量算法,DDA 尽管实现起来很简单,但是计算过程中有浮点数的运算,其效率因此不高。我们的软渲染器不会采用 DDA 算法。
中点 Bresenham 算法
Bresenham 算法才是我们今天的主角,因为它实现了完全无浮点数绘制直线,是现代应用最广范的直线生成算法。接下来我将带着你一步步推导 Bresenham 算法。
首先,直线可以用隐式方程来表示:
如下图所示, 是当前点, 和 是两个候选点, 是候选点的中点。
可以根据判别式决定下一个渲染的点是 还是 :
上述情况下的判别式则表示为:
当 时,中点 在直线下方,直线为图中黑色实线位置,取候选点 ;当 时,中点 在直线上方,直线为图中绿色虚线位置,取候选点 。
然后我们继续看后续的迭代,设当前点为 ,这时需要分类讨论:
- 若 ,此时候选点分别为 和 。判别式为 ,即相对于 的增量是 。
- 若 ,此时候选点分别为 和 。判别式为 ,即相对于 的增量是 。
接下来,只要求出 就可以得到 的递推公式了。假设 是刚好在直线上的点,则有 ,此时两个候选点的中点坐标为 ,有 。
整理一下, 的初值为 ,若 ,则 ;若 ,则 。
经观察发现,在对判定式 的迭代过程中,作为判定的只有 的正负。为了消除浮点数,可以对所有的 乘上同一个数,这个倍数就是 。注意 表示斜率。
那么,对 的迭代过程就修改为:初值为 ,若 ,则 ;若 ,则 。
到此,Bresenham 算法已推导完毕,可以写出伪代码了:
DrawLine(x0, y0, x1, y1)
dx = x1 - x0
dy = y1 - y0
y = y0
d = dx - 2 * dy
for x from x0 to x1
SetPixel(x, y)
if d < 0
y++
d += 2 * (dx - dy)
else
d -= 2 * dy
但是,上述代码只考虑了 的情况,要扩展到完整体也不难:
DrawLine(x0, y0, x1, y1)
dx = x1 - x0
dy = y1 - y0
d = dx - 2 * dy
if dx < 0
swap(x0, x1)
dx = -dx
if dy < 0
swap(y0, y1)
dy = -dy
if dx > dy
y = y0
for x from x0 to x1
SetPixel(x, y)
if d < 0
y++
d += 2 * (dx - dy)
else
d -= 2 * dy
else
x = x0
for y from y0 to y1
SetPixel(x, y)
if d < 0
x++
d += 2 * (dy - dx)
else
d -= 2 * dx
改进的 Bresenham 算法
接下来是改进的 Bresenham 算法,它是从前面所述的中点 Bresenham 算法优化而来的。
前面介绍的 Bresenham 算法用中点 与直线的位置关系来引入误差项,其实误差项有更直观的计算方法。如下图所示,我们以起点 为原点构造网格线, 为误差项。
每次 增加 ,若 ,则 增加 ;若 ,则 保持不变。
的迭代过程可描述为:初值为 ,若 ,则 ;若 ,则 。为消除浮点数,令 ,然后同样放大 倍,得到:
于是可以写出伪代码:
DrawLine(x0, y0, x1, y1)
dx = x1 - x0
dy = y1 - y0
if dx < 0
swap(x0, x1)
dx = -dx
if dy < 0
swap(y0, y1)
dy = -dy
if dx > dy
y = y0
e = -dx
for x from x0 to x1
SetPixel(x, y)
e += 2 * dy
if e > 0
y++
e -= 2 * dx
else
x = x0
e = -dy
for y from y0 to y1
SetPixel(x, y)
e += 2 * dx
if e > 0
x++
e -= 2 * dy
极简版 Bresenham
最后贴上极简版 Bresenham 算法的实现(请原谅我水平有限,该版本实在是推导不来😅)。
DrawLine(x0, y0, x1, y1)
dx = abs(x1 - x0)
dy = abs(y1 - y0)
sx = x0 < x1 ? 1 : -1
sy = y0 < y1 ? 1 : -1
err = (dx > dy ? dx : -dy) / 2
x = x0, y = y0
while (true)
SetPixel(x, y)
if (x == x1 && y == y1)
break
if err > -dx
err -= dy
x += sx
if err < dy
err += dx
y += sy