光栅化

一、三角形光栅化

三角形光栅化就是将三角面片覆盖的像素绘制出来,从而在屏幕上可见。有几个目标需要实现:

  • 不重复的:不重复绘制像素,例如相邻的三角形之间公共边覆盖到的像素应当只被绘制一次。

  • 无缝的:例如相邻的三角形之间光栅化出来应当是没有缝隙的,连续的。

遍历三角形

这一步用来确定哪些范围内含有被三角形覆盖的像素。

有几种方法:

  • 遍历三角形包围盒范围内像素,逐像素判断是否被三角形覆盖。判断使用的是边界函数算法,也称为半空间法。
  • 扫描线法。

最简单就是半空间法,虽然暴力,但是容易并行执行。

边界函数算法

判断点三角形内部

以半空间法为例,最重要的就是判断一个像素点是否在三角形的内部了。如果在内部,我们就插值并着色,否则就丢弃。

三角形每条边所在的直线分别将空间分成了两个部分。通过直线的边界函数的符号,可以判断一个点在直线的左侧还是右侧。如果一个点均在三边的同侧(即符号相同),说明点在三角形内侧;如果规定了三角形的顺序对应的正反,也可以通过符号确定当前三角形是正面还是反面(常用的是三角形逆时针方向为正面)。

对于边\(AB\),其边界函数为: \[ E_{AB}(P)=(x_B-x_A)(y_P-y_A)-(x_P-x_A)(y_B-y_A) \] 其实本质就是一个叉乘,反映了向量\(AB\)\(AP\)构成的平行四边形的有向面积。

由此可得: \[ \begin{aligned} E_{AB}(P)>0 \\ E_{BC}(P)>0 \\ E_{CA}(P)>0 \end{aligned} \]

满足上式,说明点在三角形内部。

当点在三角形边上

当边界函数值为0时,说明点可能在三角形的边界上(如图中红色点)。最简单的方法是直接将在三角形边界上的点视作在三角形内部,直接绘制。但是这样做会重复绘制共边上的像素。在不透明渲染中这样没问题,但是在半透明渲染时,多余的绘制会导致颜色不正确。因此需要一些方法来避免重复绘制。

常用top-left规则来渲染在三角形边上的像素,根据所在边的类型不同,选择是否渲染。这个方法对于水密(watertight)的模型是没问题的,但是对于面片(例如一面仅由两个三角面片构成的墙),可能出现边渲染缺失的情况。

边的类型如下图所示(注意三角形顶点顺序!图中将顺时针作为三角形正面,如果你用的是逆时针顺序为正面,就要反过来):

  • top边就是完全水平的边,并且其两端点在第三个点的上面。具体来说(以上图顺时针为例),就是边的y坐标差值为0,x坐标差值为正。
  • left边就是往上走的边(如果是逆时针,那就往下走,就是反过来嘛)。具体来说(以上图顺时针为例),就是y坐标差值为正。

综上,我们就达成了光栅化第一目标。下面给出伪代码,注意下面的伪代码顶点是定义在逆时针顺序上的,请自行转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vec2 edge0 = v[2] - v[1]; 
vec2 edge1 = v[0] - v[2];
vec2 edge2 = v[1] - v[0];

...

bool overlaps = true;
vec2 P(x + 0.5, y + 0.5);
float w0 = edge_function(v[1], v[2], P);
float w1 = edge_function(v[2], v[0], P);
float w2 = edge_function(v[0], v[1], P);
//top edge //left edge //inside
overlaps &= (w0 == 0 ? ((edge0.y == 0 && edge0.x < 0) || edge0.y < 0) : (w0 > 0);
overlaps &= (w1 == 0 ? ((edge1.y == 0 && edge1.x < 0) || edge1.y < 0) : (w1 > 0));
overlaps &= (w2 == 0 ? ((edge2.y == 0 && edge2.x < 0) || edge2.y < 0) : (w2 > 0));

if(overlaps) shade(P);

如何无缝

光栅化第二个目标是无缝。缝隙往往出现在模型的接缝处,并且出现缝的原因往往是精度误差带来的。

如果使用浮点数参与边界函数的计算,误差是无可避免的。对于接缝处的计算,需要判0,这是浮点数不擅长的。而且涉及很多乘法,这会放大误差。有一些方法可以改善,例如先做差再相乘比先相乘再做差更好;双精度浮点数。但这都是治标不治本的。

可以发现,整个光栅化整个过程中(不包括插值和着色),只涉及到乘法和加法(比如边界函数)。因此如果能转换为整数,虽然整体上可能会丢失一些精度,但是这样边界函数结果将不会有误差,接缝处的计算将永远是准确的。

因此对可以预先对三角形三个顶点四舍无入为整数,再进行后续判断。改进后的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// vec2i是vec2的整数版本,vec2到vec2i转换过程中各分量会四舍五入
vi[0] = vec2i(v[0]);
vi[1] = vec2i(v[1]);
vi[2] = vec2i(v[2]);
vec2i edge0 = vi[2] - vi[1];
vec2i edge1 = vi[0] - vi[2];
vec2i edge2 = vi[1] - vi[0];

...

bool overlaps = true;
vec2i P(x, y);
int w0 = edge_function(vi[1], vi[2], P);
int w1 = edge_function(vi[2], vi[0], P);
int w2 = edge_function(vi[0], vi[1], P);
//top edge //left edge //inside
overlaps &= (w0 == 0 ? ((edge0.y == 0 && edge0.x < 0) || edge0.y < 0) : (w0 > 0);
overlaps &= (w1 == 0 ? ((edge1.y == 0 && edge1.x < 0) || edge1.y < 0) : (w1 > 0));
overlaps &= (w2 == 0 ? ((edge2.y == 0 && edge2.x < 0) || edge2.y < 0) : (w2 > 0));

if(overlaps) shade(vec2(P));

仅仅一个像素的精度丢失,无伤大雅。

二、直线光栅化

1. 数值微分DDA

根据斜截:\(y=kx+b\)。假设\(k<1\),这样\(y\)增长比\(x\)慢,所以就可以每次向右前进一个像素,判断这个\(x\)坐标下的\(y\)是多少。

对于\(k\)的其它情况,做相应的交换即可。例如\(k>1\),就交换\(x\)\(y\),就变回\(k<1\)的情况了。

\(y_{i+1}=k(x+1)+b=(kx+b)+k=y_{i}+k\),可以发现\(x\)每加1,\(y\)\(k\)即可。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void drawlineDDA(int x0,int y0,int x1,int y1, Framebuffer &fb) {
int dx = abs(x0 - x1), dy = abs(y0 - y1);
if(dx < dy) {
swap(x0, y0);
swap(x1, y1);
}
if(x0 > x1) {
swap(x0, x1);
swap(y0, y1);
}
double k = (y1 - y0) * 1.0 / (x1 - x0);
double y = y0;
for(auto x = x0 ; x <= x1 ; ++x) {
if(dx < dy) {
fb.set(y, x);
} else {
fb.set(x, y);
}
y += k;
}
}

涉及浮点数的计算,效率较慢。

2. 中点画线算法

将直线写成\(Ax+By+C=0\)。整个方程将平面划分成三部分:\(Ax+By+C\)分别大于等于小于0三部分。\(B\)为正数时\(Ax+By+C\)大于0代表点\((x,y)\)在直线上面;小于0在下面;等于0在直线上。

\(0<k<1\)为例,\(x\)增加1时,\(y\)要么增加1,要么不变。因此通过判断\(y\)\(y+1\)中点\(y+0.5\)相对直线位置,就可以知道哪个离直线更近。

1
2
3
4
5
6
7
8
9
// 前面乘B的目的是让B系数为正
if(B * (A*(x+1) + B*(y+0.5) + C) < 0) {
// 中点在直线下面,说明(x+1,y+1)离直线更近
y = y + 1;
} else {
// 中点在直线上面,说明(x+1,y)离直线更近
y = y;
}
x = x + 1;

下面进行一些优化:

  • 如果已知下一点画在\((x_i+1,y_i+1)\),那么接下来要考虑\(M_1(x_i+2,y_i+1.5)\),带入\(M_1\)到直线方程,有 \[ d_1=A(x_i+2)+B(y_i+1.5)+C=(A(x_i+1)+B(y_i+0.5)+C)+A+B=d_0+A+B \]

  • 如果已知下一个点画在\((x_i+1,y_i)\),那么接下来要考虑\(M_1(x_i+2,y_i+0.5)\),带入\(M_1\)到直线方程,有 \[ d_1=A(x_i+2)+B(y_i+0.5)+C=(A(x_i+1)+B(y_i+0.5)+C)+A=d_0+A \]

因此判断中点和直线相对位置这里可以用增量的方式计算,避免了乘法。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 初值
double d = A * (x0 + 1) + B * (y0 + 0.5) + C;
// 前面乘B的目的是让B系数为正
if(B * d < 0) {
// 中点在直线下面,说明(x+1,y+1)离直线更近
y = y + 1;
d = d + A + B;
} else {
// 中点在直线上面,说明(x+1,y)离直线更近
y = y;
d = d + A;
}
x = x + 1;

可以发现只有\(0.5\)这个浮点数,可以全部乘以2,避免浮点数的使用,全部用整数代替。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 初值
int d = 2 * A * (x0 + 1) + B * (2 * y0 + 1) + 2 * C;
// 前面乘B的目的是让B系数为正
if(B * d < 0) {
// 中点在直线下面,说明(x+1,y+1)离直线更近
y = y + 1;
d = d + 2 * (A + B);
} else {
// 中点在直线上面,说明(x+1,y)离直线更近
y = y;
d = d + 2 * A;
}
x = x + 1;

对于\(k\)的其它情况,做各种转化为\(0<k<1\)即可。注意如果\(k<0\),中点判断规则是反过来的。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void drawlineMid(int x0,int y0,int x1,int y1, Framebuffer &fb) {
int dx = abs(x0 - x1), dy = abs(y0 - y1);
if(dx < dy) {
swap(x0, y0);
swap(x1, y1);
}
if(x0 > x1) {
swap(x0, x1);
swap(y0, y1);
}

int A = y1 - y0;
int B = x0 - x1;
int C = -(A * x0 + B * y0);

int sgn = y0 < y1 ? 1 : -1;

int d = 2 * A * (x0 + 1) + B * (2 * y0 + sgn) + 2 * C;

int y = y0;
for(auto x = x0 ; x <= x1 ; ++x) {
if(dx < dy) {
fb.set(y, x);
} else {
fb.set(x, y);
}
if(sgn * B * d < 0) {
d += 2 * (A + sgn * B);
y += sgn;
} else d += 2 * A;
}
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
#include <iostream>
using namespace std;

class Framebuffer {
public:
Framebuffer(int w, int h) : width(w), height(h), buffer(new bool[w * h]) {
clear();
}
~Framebuffer() {delete [] buffer;}
bool set(int x, int y, bool value = true) {
if(x < 0 || x >= width || y < 0 || y >= height) return false;
buffer[y * width + x] = value;
return true;
}
void print() {
for(int i = 0; i < 2 * width + 2; i++) putchar('o');
putchar('\n');
for(int i = height - 1; i >= 0; i--) {
putchar('o');
for(int j = 0; j < width; j++) {
int p = i * width + j;
if(buffer[p]) printf("XX");
else printf(" ");
}
putchar('o');
putchar('\n');
}
for(int i = 0; i < 2 * width + 2; i++) putchar('o');
}
void clear() {
for(int i = height - 1; i >= 0; i--) {
for(int j = 0; j < width; j++) {
int p = i * width + j;
buffer[p] = false;
}
}
}

private:
int width, height;
bool* buffer;
};

int w = 50, h = 50;

void drawlineDDA(int x0,int y0,int x1,int y1, Framebuffer &fb) {
int dx = abs(x0 - x1), dy = abs(y0 - y1);
if(dx < dy) {
swap(x0, y0);
swap(x1, y1);
}
if(x0 > x1) {
swap(x0, x1);
swap(y0, y1);
}
double k = (y1 - y0) * 1.0 / (x1 - x0);
double y = y0;
for(auto x = x0 ; x <= x1 ; ++x) {
if(dx < dy) {
fb.set(y, x);
} else {
fb.set(x, y);
}
y += k;
}
}


void drawlineMid(int x0,int y0,int x1,int y1, Framebuffer &fb) {
int dx = abs(x0 - x1), dy = abs(y0 - y1);
if(dx < dy) {
swap(x0, y0);
swap(x1, y1);
}
if(x0 > x1) {
swap(x0, x1);
swap(y0, y1);
}

int A = y1 - y0;
int B = x0 - x1;
int C = -(A * x0 + B * y0);

int sgn = y0 < y1 ? 1 : -1;

int d = 2 * A * (x0 + 1) + B * (2 * y0 + sgn) + 2 * C;

int y = y0;
for(auto x = x0 ; x <= x1 ; ++x) {
if(dx < dy) {
fb.set(y, x);
} else {
fb.set(x, y);
}
if(sgn * B * d < 0) {
d += 2 * (A + sgn * B);
y += sgn;
} else d += 2 * A;
}
}

int main() {
Framebuffer fb(w, h);
// drawlineDDA(2, 2, 18, 10, fb);
drawlineMid(2, 2, 18, 10, fb);
fb.print();
}

参考

作者

limil

发布于

2024-09-25

更新于

2025-04-05

许可协议