手把手教你软渲染 #2 - 两大直线算法

773 阅读4分钟

前言

众所周知,模型是由很多个小三角面组成的,而三角面又是由三条边组成的,所以要想渲染模型,第一步是画直线。这一节要详细讲三个经典的直线算法。

在上一讲中我们讨论了如何用 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 去处理。

概述

由于显示器精度总是有限的,所以在屏幕上只能用离散的点去逼近直线,如下图所示:

LineRender

直线算法的目标就是以最少的计算量,绘制出最逼近指定直线路径上的最佳像素点。该篇博客主要讲述两种画直线的算法:DDA 算法和 Bresenham 算法。

DDA 算法

第一个要介绍的算法叫做 DDA (Digital Differential Analyzer,数值微分法),顾名思义,该方法是用微积分的思想来画直线。

直线最简单的表示方法为斜截式:

y=kx+ty = kx + t

给定直线的两个端点 P0(x0,y0)P_0(x_0,y_0)P1(x1,y1)P_1(x_1,y_1),则微分形式可以表示为:

k=ΔxΔy=x1x0y1y0k=\frac{\Delta x}{\Delta y}=\frac{x_1-x_0}{y_1-y_0}

基本思想是沿着某一个轴递增,计算另一个轴的增量,根据斜率拆解成两种情况:k<1k<1k1k\ge1

DDA

综合考虑两种情况,写成伪代码就是:

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 算法。

首先,直线可以用隐式方程来表示:

F(x,y)=ykxt=0F(x,y)=y-kx-t=0

如下图所示,Pi(xi,yi)P_i(x_i,y_i) 是当前点,Pu(xi+1,yi+1)P_u(x_i+1,y_i+1)Pd(xi+1,yi)P_d(x_i+1,y_i) 是两个候选点,M(xi+1,yi+0.5)M(x_i+1,y_i+0.5) 是候选点的中点。

Bresenham-1

可以根据判别式决定下一个渲染的点是 PuP_u 还是 PdP_d

d=F(xM,yM)d=F(x_M,y_M)

上述情况下的判别式则表示为:

di=F(xi+1,yi+0.5)=yi+0.5k(xi+1)td_i=F(x_i+1,y_i+0.5)=y_i+0.5-k(x_i+1)-t

d<0d<0 时,中点 MM 在直线下方,直线为图中黑色实线位置,取候选点 PuP_u;当 d0d\ge0 时,中点 MM 在直线上方,直线为图中绿色虚线位置,取候选点 PdP_d

然后我们继续看后续的迭代,设当前点为 Pi+1(xi+1,yi+1)P_{i+1}(x_{i+1},y_{i+1}),这时需要分类讨论:

  1. di<0d_i<0,此时候选点分别为 Pu(xi+2,yi+2)P_u(x_i+2,y_i+2)Pd(xi+2,yi+1)P_d(x_i+2,y_i+1)。判别式为 di+1=F(xi+2,yi+1.5)=yi+1.5k(xi+2)t=di+1kd_{i+1}=F(x_i+2,y_i+1.5)=y_i+1.5-k(x_i+2)-t=d_i+1-k,即相对于 did_i 的增量是 1k1-k

Bresenham-2

  1. d0d \ge 0,此时候选点分别为 Pu(xi+2,yi+1)P_u(x_i+2,y_i+1)Pd(xi+2,yi)P_d(x_i+2,y_i)。判别式为 di+1=F(xi+2,yi+0.5)=dikd_{i+1}=F(x_i+2,y_i+0.5)=d_i-k,即相对于 did_i 的增量是 k-k

Bresenham-3

接下来,只要求出 d0d_0 就可以得到 dd 的递推公式了。假设 P0(x0,y0)P_0(x_0,y_0) 是刚好在直线上的点,则有 F(x0,y0)=y0kx0t=0F(x_0,y_0)=y_0-kx_0-t=0,此时两个候选点的中点坐标为 M(x0+1,y0+0.5)M(x_0+1,y_0+0.5),有 d0=F(x0+1,y0+0.5)=y0kx0t+0.5k=0.5kd_0=F(x_0+1,y_0+0.5)=y_0-kx_0-t+0.5-k=0.5-k

Bresenham-4

整理一下,dd 的初值为 d0=0.5kd_0=0.5-k,若 di<0d_i<0,则 di+1=di+1kd_{i+1}=d_i+1-k;若 di0d_i\ge0,则 di+1=dikd_{i+1}=d_i-k

经观察发现,在对判定式 dd 的迭代过程中,作为判定的只有 did_i 的正负。为了消除浮点数,可以对所有的 dd 乘上同一个数,这个倍数就是 2Δx2\Delta x。注意 k=Δy/Δxk=\Delta y / \Delta x 表示斜率。

那么,对 dd 的迭代过程就修改为:初值为 d0=Δx2Δyd_0=\Delta x-2\Delta y,若 di<0d_i<0,则 di+1=di+2Δx2Δyd_{i+1}=d_i+2\Delta x-2\Delta y;若 di0d_i\ge0,则 di+1=di2Δyd_{i+1}=d_i-2\Delta y

到此,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

但是,上述代码只考虑了 k[0,1]k \in [0, 1] 的情况,要扩展到完整体也不难:

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 算法用中点 MM 与直线的位置关系来引入误差项,其实误差项有更直观的计算方法。如下图所示,我们以起点 P0(x0,y0)P_0(x_0,y_0) 为原点构造网格线,dd 为误差项。

Bresenham-5

每次 xx 增加 11,若 d>0.5d>0.5,则 yy 增加 11;若 d0.5d\le0.5,则 yy 保持不变。

dd 的迭代过程可描述为:初值为 d0=0d_0=0,若 di>0.5d_i>0.5,则 di+1=di1, yi+1=yi+1d_{i+1}=d_i-1,\ y_{i+1}=y_i+1;若 di0.5d_i\le0.5,则 di+1=di, yi+1=yid_{i+1}=d_i,\ y_{i+1}=y_i。为消除浮点数,令 e=d0.5e=d-0.5,然后同样放大 2Δx2 \Delta x 倍,得到:

{e0=Δxei+1=ei+2Δyxi+1=xi+1e>0yi+1=yi+1, ei+1=ei+12Δxe0yi+1=yi\left\{ \begin{aligned} & e_0 = -\Delta x \\ & e_{i+1} = e_i + 2 \Delta y \\ & x_{i+1} = x_i + 1 \\ & e>0 \Rightarrow y_{i+1} = y_i + 1,\ e_{i+1}=e_{i+1}-2 \Delta x \\ & e\le0 \Rightarrow y_{i+1} = y_i \end{aligned} \right.

于是可以写出伪代码:

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