三角光栅化和背面剔除
绘制三角形的最简单的方法,扫线:
#include <iostream>
#include "model.h"
#include "tgaimage.h"
void line(int x0, int y0, int x1, int y1, TGAImage& image, TGAColor color) {
bool steep = false;
if (std::abs(x0 - x1) < std::abs(y0 - y1)) {
std::swap(x0, y0);
std::swap(x1, y1);
steep = true;
}
if (x0 > x1) {
std::swap(x0, x1);
std::swap(y0, y1);
}
int dx = x1 - x0;
int dy = y1 - y0;
int derror2 = std::abs(dy) * 2;
int error2 = 0;
int y = y0;
for (int x = x0; x <= x1; x++) {
if (steep) {
image.set(y, x, color);
}
else {
image.set(x, y, color);
}
error2 += derror2;
if (error2 > dx) {
y += (y1 > y0 ? 1 : -1);
error2 -= dx * 2;
}
}
}
void line(Vec2i v0, Vec2i v1, TGAImage& image, TGAColor color)
{
line(v0.x, v0.y, v1.x, v1.y, image, color);
}
void drawTriangle(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage& image, TGAColor color)
{
line(v0, v1, image, color);
line(v1, v2, image, color);
line(v2, v0, image, color);
}
int main() {
Vec2i v0{ 200, 200 };
Vec2i v1{ 500, 500 };
Vec2i v2{ 200, 400 };
int width = 800;
int height = 800;
TGAColor white{255, 0, 0, 255 };
TGAImage image(width, height, TGAImage::RGB);
drawTriangle(v0, v1, v2, image, white);
image.write_tga_file("Triangle.tga");
}
最简单的三角形绘制方法就是使用扫线,将点两两绘制直线,最终得到三角形,结果如下:
现在我们得到了三角形边框的绘制,但是我们如何填充这个三角形呢?
在这之前,我们需要讨论绘制一个绘制三角形方法所需要的特征:
- 它应该是简单而高效的
- 它应该具有对称性,最终得到的图像不会随着输入顶点的顺序改变而改变
- 如果两个三角形有共同的两个顶点,那么这两个三角形之间将没有空隙
- 我们还可以提出更多的需求,而下面的则是在传统扫描线中经常使用的:
- 根据三角形的y坐标对顶点进行排序
- 同时对三角形的左侧和右侧进行光栅化
- 在左右边界点之间绘制一条水平线段。
上面的需求中,我们需要对三角形的y坐标进行排序,因此我们可以在drawTriangle函数中进行排序,然后将顺序从小到大排序
void drawTriangle(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage& image, TGAColor color)
{
if (v0.y > v1.y) std::swap(v0, v1);
if (v0.y > v2.y) std::swap(v0, v2);
if (v1.y > v2.y) std::swap(v1, v2);
line(v0, v1, image, color);
line(v1, v2, image, color);
line(v2, v0, image, color);
}
在对y坐标排序前,输入顺序将会影响三角形的绘制,结果如下:
通过对输入顶点的y大小进行排序,这样我们就完成了绘制时的对称性,即输入顶点的顺序并不会影响最终绘制三角形的结果
现在我们已经完成了绘制三角形边框的对称性,接下来我们需要考虑的就是如何填充三角形中的像素。因为在三角形中我们已经将顶点的y坐标值排序,因此我们可以从最低的y坐标值进行遍历,分别计算两边的x值,代码如下:
void drawTriangle(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage& image, TGAColor color)
{
//对输入的顶点y坐标进行排序,按照v0->v1升序排列
if (v0.y > v1.y) std::swap(v0, v1);
if (v0.y > v2.y) std::swap(v0, v2);
if (v1.y > v2.y) std::swap(v1, v2);
int total_height = v2.y - v0.y;
int semi_height = v1.y - v0.y + 1; //可能相等,因此加1,防止之后除数为0
for (int y = v0.y; y <= v1.y; y++)
{
//计算两侧的x值
float alpha = (float)(y - v0.y) / total_height;
float beta = (float)(y - v0.y) / semi_height;
Vec2i A = v0 + (v2 - v0) * alpha;
Vec2i B = v0 + (v1 - v0) * beta;
image.set(A.x, y, red);
image.set(B.x, y, green);
}
}
绘制结果如下,可以看到绘制的线条会有空洞,这是之前提到的直线的陡峭和平缓造成的,但是这里我们不需要对其进行改动,因为我们的目的是填充同一y坐标轴下,坐标轴x从左到右填充颜色,因此空洞部分也会被填充,代码如下:
void drawTriangle(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage& image, TGAColor color)
{
//对输入的顶点y坐标进行排序,按照v0->v1升序排列
if (v0.y > v1.y) std::swap(v0, v1);
if (v0.y > v2.y) std::swap(v0, v2);
if (v1.y > v2.y) std::swap(v1, v2);
int total_height = v2.y - v0.y;
int semi_height = v1.y - v0.y + 1; //可能相等,因此加1,防止之后除数为0
for (int y = v0.y; y <= v1.y; y++)
{
float alpha = (float)(y - v0.y) / total_height;
float beta = (float)(y - v0.y) / semi_height;
Vec2i A = v0 + (v2 - v0) * alpha;
Vec2i B = v0 + (v1 - v0) * beta;
if (A.x > B.x) std::swap(A, B);
for (int i = A.x; i <= B.x; i++)
{
image.set(i, y, color);
}
image.set(A.x, y, red);
image.set(B.x, y, green);
}
}
我们完成了下半部分的三角形绘制,同样道理,我们可以使用类似方式计算上半部分的三角形。最终代码如下:
void drawTriangle(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage& image, TGAColor color)
{
//对输入的顶点y坐标进行排序,按照v0->v1升序排列
if (v0.y > v1.y) std::swap(v0, v1);
if (v0.y > v2.y) std::swap(v0, v2);
if (v1.y > v2.y) std::swap(v1, v2);
//三角形下半部分
int total_height = v2.y - v0.y;
int semi_height = v1.y - v0.y + 1; //可能相等,因此加1,防止之后除数为0
for (int y = v0.y; y <= v1.y; y++)
{
float alpha = (float)(y - v0.y) / total_height;
float beta = (float)(y - v0.y) / semi_height;
Vec2i A = v0 + (v2 - v0) * alpha;
Vec2i B = v0 + (v1 - v0) * beta;
if (A.x > B.x) std::swap(A, B);
for (int i = A.x; i <= B.x; i++)
{
image.set(i, y, color);
}
}
//三角形上半部分
semi_height = v2.y - v1.y + 1; //可能相等,因此加1,防止之后除数为0
for (int y = v1.y; y <= v2.y; y++)
{
float alpha = (float)(y - v0.y) / total_height;
float beta = (float)(y - v1.y) / semi_height;
Vec2i A = v0 + (v2 - v0) * alpha;
Vec2i B = v1 + (v2 - v1) * beta;
if (A.x > B.x) std::swap(A, B);
for (int i = A.x; i <= B.x; i++)
{
image.set(i, y, color);
}
}
}
上述代码有一部分重复了两边,因此我们可以对其优化,合成一个代码块。
void drawTriangle(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage& image, TGAColor color)
{
//对输入的顶点y坐标进行排序,按照v0->v1升序排列
if (v0.y > v1.y) std::swap(v0, v1);
if (v0.y > v2.y) std::swap(v0, v2);
if (v1.y > v2.y) std::swap(v1, v2);
//三角形下半部分
int total_height = v2.y - v0.y;
//这里的遍历是从v0到v2
for (int y = v0.y; y <= v2.y; y++)
{
//判断是上半三角形还是下半三角形
bool half = y >= v1.y ? true : false;
int semi_height = half ? v2.y - v1.y + 1 : v1.y - v0.y + 1;//可能相等,因此加1,防止之后除数为0
float alpha = (float)(y - v0.y) / total_height;
float beta = half? (float)(y - v1.y) / semi_height : (float)(y - v0.y) / semi_height;
Vec2i A = v0 + (v2 - v0) * alpha;
Vec2i B = half? v1 + (v2 - v1) * beta: v0 + (v1 - v0) * beta;
if (A.x > B.x) std::swap(A, B);
for (int i = A.x; i <= B.x; i++)
{
image.set(i, y, color);
}
}
}
###行扫描
行扫描的源代码虽然并不复杂,但有点混乱。此外,它实际上是一种为单线程CPU编程设计的老派方法。下面为伪代码:
triangle(vec2 points[3]) {
vec2 bbox[2] = find_bounding_box(points);
for (each pixel in the bounding box) {
if (inside(points, pixel)) {
put_pixel(pixel);
}
}
}
我们之前的实现的方法是首先确定三角形的边界,然后在边界中确定各个像素的颜色值,但是上面的伪代码中,我们不需要那么繁琐的过程,首先我们根据三角形确定一个可以将其包围的正方形,然后就是对正方形中的像素进行遍历,确定其是否在这个三角形内部,如果在三角形内部,则对这个像素点进行上色。
重心坐标
首先我们需要知道什么是重心坐标,给定一个二维的三角形
A
B
C
ABC
ABC和点
P
P
P,用一个二维的笛卡尔坐标系表示,我们的目标是找到点
P
P
P相对于三角形
A
B
C
ABC
ABC的重心坐标,这意味着我们需要寻找三个数字
(
1
−
u
−
v
,
u
,
v
)
(1-u-v, u,v)
(1−u−v,u,v),这样我们就可以如下找到
P
P
P:
P
=
(
1
−
u
−
v
)
A
+
u
B
+
v
C
P
=
A
−
u
A
−
v
A
+
u
B
+
v
C
(
A
−
P
)
+
u
(
B
−
A
)
+
v
(
C
−
A
)
=
0
P=(1- u - v)A + uB + vC \\ P = A - uA - vA + uB + vC \\ (A- P) + u(B-A) + v(C-A) = 0
P=(1−u−v)A+uB+vCP=A−uA−vA+uB+vC(A−P)+u(B−A)+v(C−A)=0
这里需要明确重心和重心坐标是不一样的,重心坐标是一系列权重的组合,而重心则是其中的一个组合。上面的式子我们可以理解为使用三个权重
(
1
−
u
−
v
,
u
,
v
)
(1-u-v,u,v)
(1−u−v,u,v)在三个顶点ABC上。点P的坐标正好等于这个重心坐标。我们也可以换一种方式表达,将上面的式子展开再组合,我们就可以知道点P的坐标
(
u
,
v
)
(u,v)
(u,v)是在
(
A
,
A
B
→
,
A
C
→
)
(A, \overrightarrow{AB},\overrightarrow{AC})
(A,AB,AC)的基础上得到:
P
=
A
+
u
A
B
→
+
v
A
C
→
P=A + u\overrightarrow{AB} + v\overrightarrow{AC}
P=A+uAB+vAC
上述式子可以等价于下面
P
A
→
+
u
A
B
→
+
v
A
C
→
=
0
\overrightarrow{PA} + u\overrightarrow{AB} + v\overrightarrow{AC} = 0
PA+uAB+vAC=0
我们知道这个矢量方程可以分解为两个变量x,y
{
P
A
x
→
+
u
A
B
x
→
+
v
A
C
x
→
=
0
P
A
y
→
+
u
A
B
y
→
+
v
A
C
y
→
=
0
\begin{cases}\overrightarrow{PA_x} + u\overrightarrow{AB_x} + v\overrightarrow{AC_x} = 0 \\ \overrightarrow{PA_y} + u\overrightarrow{AB_y} + v\overrightarrow{AC_y} = 0 \end{cases}
{PAx+uABx+vACx=0PAy+uABy+vACy=0
我们可以将上述线性方程拆分为矩阵相乘:
{
[
u
v
1
]
[
A
B
x
→
A
C
x
→
P
A
x
→
]
=
0
[
u
v
1
]
[
A
B
y
→
A
C
y
→
P
A
y
→
]
=
0
\begin{cases} \left[\begin{array}{l}u & v & 1\end{array}\right] \left[\begin{array}{l} \overrightarrow{AB_x} \\ \overrightarrow{AC_x} \\ \overrightarrow{PA_x}\end{array}\right]=0 \\ \\ \left[\begin{array}{l}u & v & 1\end{array}\right] \left[\begin{array}{l}\overrightarrow{AB_y} \\ \overrightarrow{AC_y}\\ \overrightarrow{PA_y}\end{array}\right] = 0 \end{cases}
⎩
⎨
⎧[uv1]
ABxACxPAx
=0[uv1]
AByACyPAy
=0
上述的式子意味着我们寻找一个同时与
(
A
B
x
,
A
C
x
,
P
A
x
)
(AB_x, AC_x,PA_x)
(ABx,ACx,PAx)和
(
A
B
y
,
A
C
y
,
P
A
y
)
(AB_y, AC_y,PA_y)
(ABy,ACy,PAy)正交的向量
(
u
,
v
,
1
)
(u,v,1)
(u,v,1),要找到平面上两条直线的交点(这正是我们在这里所做的),计算一个叉积就足够了。我们迭代给定三角形的边界框的所有像素。对于每个像素,我们计算其重心坐标。如果它至少有一个负分量,那么像素就在三角形之外。
现在我们所需要做的就是得到最终的 [ u , v , 1 ] [u,v,1] [u,v,1],我们可以通过对上面两个分量进行叉乘得到。代码如下:
Vec3f barycentric(Vec2i* pts, Vec2i P) {
//uAB + vAC + PA = 0
//将上述向量的x分量和y分量拆开,并且做叉积,可以得到[u, v, 1](还需要归一化)
Vec3f u = Vec3f(pts[1].x - pts[0].x, pts[2].x - pts[0].x, pts[0].x - P.x) ^
Vec3f(pts[1].y - pts[0].y, pts[2].y - pts[0].y, pts[0].y - P.y);
if (std::abs(u.z) < 1) return Vec3f(-1, 1, 1);
//返回[u,v,1]
return Vec3f(1.f - (u.x + u.y) / u.z, u.y / u.z, u.x / u.z);
}
//改进的三角形渲染算法
void drawTriangle(Vec2i* pts, TGAImage& image, TGAColor color)
{
//确定三角形的正方形框
Vec2i bboxmin{ image.height() - 1, image.width() - 1 };
Vec2i bboxmax{ 0 ,0 };
Vec2i bboxclamp{ image.height() - 1, image.width() - 1 };
//遍历三角形顶点,确定四边形边框
for (int i = 0; i < 3; i++)
{
bboxmin.x = std::max(0, std::min(bboxmin.x, pts[i].x));
bboxmin.y = std::max(0, std::min(bboxmin.y, pts[i].y));
bboxmax.x = std::max(0, std::max(bboxmax.x, pts[i].x));
bboxmax.y = std::max(0, std::max(bboxmax.y, pts[i].y));
}
//遍历框内像素,确定像素点是否在三角形内部
for (int i = bboxmin.x; i <= bboxmax.x; i++) {
for (int j = bboxmin.y; j <= bboxmax.y; j++) {
Vec3f bc_screen = barycentric(pts, {i,j});
//当[u,v,1]其中有一个分量为负时,说明改点不在三角形内部
if (bc_screen.x < 0 || bc_screen.y < 0 || bc_screen.z < 0) continue;
image.set(i, j, color);
}
}
}
现在我们有了改进的三角形渲染算法,我们可以尝试使用随机上色,对之前我们使用的模型中的每个面进行着色,代码如下:
int main() {
int width = 800;
int height = 800;
TGAImage image(width, height, TGAImage::RGB);
Model* head = new Model("./obj/african_head/african_head.obj");
for (int i = 0; i < head->nfaces(); i++)
{
std::vector<int> face = head->face(i);
Vec2i screen_coords[3];
for (int j = 0; j < 3; j++) {
Vec3f world_coords = head->vert(face[j]);
screen_coords[j] = Vec2i((world_coords.x + 1.) * width / 2., (world_coords.y + 1.) * height / 2.);
}
Vec2i* p_coords = &screen_coords[0];
drawTriangle(p_coords, image, TGAColor{ std::uint8_t(rand() % 255), std::uint8_t(rand() % 255), std::uint8_t(rand() % 255), std::uint8_t(255) });
}
image.write_tga_file("head_rand_color.tga");
delete head;
}
添加光照
接下来我们可以去掉上面的随机颜色,在三角形的面上增加光照。根据我们的生活常识,当光照与多边形面正交的时候,此时多边形面的光照强度将会最大。
当光照的向量与多边形平行的时候,平面没有光照。换言之,光照强度等于光照向量与给定三角形法线的点击,而三角形的法线可以简单的由三角形两边都叉积得到。
int main() {
int width = 800;
int height = 800;
TGAImage image(width, height, TGAImage::RGB);
Vec3f lightVec{ 0, 0, 1 };
Model* head = new Model("./obj/african_head/african_head.obj");
for (int i = 0; i < head->nfaces(); i++)
{
std::vector<int> face = head->face(i);
//顶点世界坐标
Vec3f world_coords[3]{};
//顶点屏幕坐标
Vec2i screen_coords[3]{};
for (int j = 0; j < 3; j++) {
world_coords[j] = head->vert(face[j]);
screen_coords[j] = Vec2i((world_coords[j].x + 1.) * width / 2., (world_coords[j].y + 1.) * height / 2.);
}
//三角形法线向量,三角形两边叉乘得到法线向量
Vec3f n = (world_coords[0] - world_coords[1]) ^ (world_coords[1] - world_coords[2]);
n.normalize();
float intensity = lightVec * n;
Vec2i* p_coords = &screen_coords[0];
//背面剔除
if (intensity > 0)
drawTriangle(p_coords, image, TGAColor{ std::uint8_t(intensity * 255), std::uint8_t(intensity * 255), std::uint8_t(intensity * 255), std::uint8_t(255) });
}
image.write_tga_file("head_rand_color.tga");
delete head;
}
光照度点积可以是负的。这意味着光线来自多边形的后面。如果场景建模良好,我们可以简单地丢弃这个三角形。这使我们能够快速移除一些不可见的三角形。这被称为背面剔除。