上一个项目GitHub项目——迷你渲染器(1)主要讲解了tiny renderer的环境搭配,以及手工实现了Bresenham画线算法,最后导入了一个obj模型,并简单分析了一下obj文件的组成和如何读取,接下来让我们继续完善这个项目。
在本文中将主要讲解如何对一个三角形进行内部填充。
传统方法:扫描线算法
既然我们已经有了画线的方法,那么在屏幕上绘制一个三角形应该也不是很难。
这里对之前画线函数line的代码稍微进行了修改,将x, y坐标合并为一个Vec2i类型的变量。通过triangle函数,我们将三角形的三个顶点一次进行连线,便可以画出三个不同颜色的三角形。
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage& image, TGAColor color) {
line(t0, t1, image, color);
line(t1, t2, image, color);
line(t2, t0, image, color);
}
int main(int argc, char** argv) {
TGAImage image(width, height, TGAImage::RGB);
Vec2i t0[3] = { Vec2i(10, 70), Vec2i(50, 160), Vec2i(70, 80) };
Vec2i t1[3] = { Vec2i(180, 50), Vec2i(150, 1), Vec2i(70, 180) };
Vec2i t2[3] = { Vec2i(180, 150), Vec2i(120, 160), Vec2i(130, 180) };
triangle(t0[0], t0[1], t0[2], image, red);
triangle(t1[0], t1[1], t1[2], image, white);
triangle(t2[0], t2[1], t2[2], image, green);
...
return 0;
}
接下来开始思考,既然三角形的边框已经绘制出来了,那么怎么对三角形的内部进行填充呢?
最简单原始的方法被称为扫描线算法(还记得这好像是我本科计算机图形学的时候老师教我们的,但当时应该是只讲了原理,没有具体实现)。简单来说就是用一条水平线对一个三角形从顶而下进行扫面,在某一条扫面线中,遇到左边界便开始填充颜色,遇到右边界就停止。
第一步需要标记三角形的左右边界。对于三角形的三个顶点,可以按y轴坐标进行排序:
t0:y值最大(最上方),t2:y值最小(最下方),t1:位于t0和t2之间。t0和t2两个顶点的连线用红色表示,其余两条边用绿色表示。
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage& image, TGAColor color) {
// 对于y坐标,应该满足t0.y > t1.y > t2.y
if (t0.y < t1.y) std::swap(t0, t1);
if (t0.y < t2.y) std::swap(t0, t2);
if (t1.y < t2.y) std::swap(t2, t1);
line(t0, t1, image, green);
line(t1, t2, image, green);
line(t2, t0, image, red);
}
这样画出的三个三角形是这个样子的:
其实这个时候已经可以看出来,对于一条扫面线,遇到红色边开始填充,直到绿色边界结束。但这个时候绿色边界实际上是由两条线段连成的,所以填充的时候我们需要分段进行,用一条过t1的水平线将三角形分成上下两个部分。
从上半部分开始:
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage& image, TGAColor color) {
// 对于y坐标,应该满足t0.y > t1.y > t2.y
if (t0.y < t1.y) std::swap(t0, t1);
if (t0.y < t2.y) std::swap(t0, t2);
if (t1.y < t2.y) std::swap(t2, t1);
int total_height = t0.y - t2.y + 1; // 三角形最大垂直高度
int segment_height = t0.y - t1.y + 1; // 上半部分三角形高度
for (int y = t0.y; y >= t1.y; y--) {
float alpha = float(t0.y - y) / total_height; // float强制转换必不可少!
float beta = float(t0.y - y) / segment_height;
Vec2i A = t0 + (t2 - t0) * alpha;
Vec2i B = t0 + (t1 - t0) * beta;
if (A.x > B.x) std::swap(A, B);
for (int x = A.x; x <= B.x; x++) {
image.set(x, y, color);
}
image.set(A.x, y, red);
image.set(B.x, y, green);
}
}
虽然这样画出来的边出现了上一篇文章的中的不连续现象,但其实没有关系,因为填充之后这种不连续现象便会消失。
接下来继续把下半部分填充完成
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage& image, TGAColor color) {
// 对于y坐标,应该满足t0.y > t1.y > t2.y
if (t0.y < t1.y) std::swap(t0, t1);
if (t0.y < t2.y) std::swap(t0, t2);
if (t1.y < t2.y) std::swap(t2, t1);
int total_height = t0.y - t2.y + 1; // 三角形最大垂直高度
int segment_height = t0.y - t1.y + 1; // 上半部分三角形高度
for (int y = t0.y; y > t1.y; y--) {
float alpha = float(t0.y - y) / total_height; // float强制转换必不可少!
float beta = float(t0.y - y) / segment_height;
Vec2i A = t0 + (t2 - t0) * alpha;
Vec2i B = t0 + (t1 - t0) * beta;
if (A.x > B.x) std::swap(A, B);
for (int x = A.x; x <= B.x; x++) {
image.set(x, y, color);
}
}
for (int y = t1.y; y >= t2.y; y--) {
float alpha = float(t0.y - y) / total_height; // float强制转换必不可少!
float beta = float(t1.y - y) / (total_height - segment_height + 1);
Vec2i A = t0 + (t2 - t0) * alpha;
Vec2i B = t1 + (t2 - t1) * beta;
if (A.x > B.x) std::swap(A, B);
for (int x = A.x; x <= B.x; x++) {
image.set(x, y, color);
}
}
}
至此我们已经完成了三角形的填充。虽然在triangle函数中使用了两次for循环,显得代码有点冗余重复,但是我们可以很简单地进行合并简化,这里便不再赘诉了。
填充算法改进
扫面线算法看起来不是很复杂,但实际上这是一种单线程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);
}
}
}
文章接下来用了很大的一块篇幅,用重心坐标说明如何判断某个点是否在三角形内部。这部分内容在闫老师的GAMES101课程中也讲解过,结论是使用三角形的每条边向量与顶点和某点P的向量进行叉乘,如果结果均为同号,则点P在三角形内部。
对于如图三角形,将三个顶点ABC按同一个方向依次相连,形成AB、BC和CA三个向量 。
如果满足: or
则说明P点在三角形ABC内部。
平面着色(Flat Shading render)
现在用我们的三角形填充算法对上一篇文章中的线框模型进行渲染
for (int i=0; i<model->nfaces(); i++) {
std::vector<int> face = model->face(i);
Vec2i screen_coords[3];
for (int j=0; j<3; j++) {
Vec3f world_coords = model->vert(face[j]);
screen_coords[j] = Vec2i((world_coords.x+1.)*width/2., (world_coords.y+1.)*height/2.);
}
triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(rand()%255, rand()%255, rand()%255, 255));
}
采用随机颜色渲染可以得到以下结果:
考虑到现实情况,人的脸部模型不可能是这样五彩斑斓的(of course),人的视觉效果受到光线强弱影响,如果光线直射某个区域,则该区域会呈现高亮状态;同理,平行于光线的地方将不可视。
光线与平面的夹角可以用三角形的法向量和光线向量的点积表示(法线可以由两条边叉乘求得)。如果点积为负,说明光线从平面后方射来,那么我们可以快速的忽略掉这个三角形。
Vec3f light_dir(0,0,-1); // define light_dir
for (int i=0; i<model->nfaces(); i++) {
std::vector<int> face = model->face(i);
Vec2i screen_coords[3];
Vec3f world_coords[3];
for (int j=0; j<3; j++) {
Vec3f v = model->vert(face[j]);
screen_coords[j] = Vec2i((v.x+1.)*width/2., (v.y+1.)*height/2.);
world_coords[j] = v;
}
Vec3f n = (world_coords[2]-world_coords[0])^(world_coords[1]-world_coords[0]);
n.normalize();
float intensity = n*light_dir;
if (intensity>0) {
triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(intensity*255, intensity*255, intensity*255, 255));
}
}
考虑光照强度,我们可到如下结果:
以上就是关于三角形渲染着色的内容,现在我们已经可以获得一个看起来不错的模型了,接下来的任务就是在这个模型中添加更多的细节。