翻译
1 填充三角形
大家好,是我
更准确地说,它是在我们将在接下来的一两个小时内创建的程序中渲染的我的脸模型。 上次我们画了一个三维模型的金属丝网。 这一次,我们将填充多边形,或者更确切地说是三角形。 事实上,OpenGL 几乎可以对任何多边形进行三角剖分,因此无需考虑复杂的情况。
提醒一下,这个系列的文章就是让你自己编程的。 当我说在两个小时内你可以画一张像上面那样的图时,我不是指阅读我的代码的时间。 是时候从头开始写代码了。 此处提供我的代码纯粹是为了将您的(工作)程序与我的程序进行比较。 我是一个糟糕的程序员,很可能你是一个更好的人。 不要简单地复制粘贴我的代码。 欢迎任何意见和问题。
2 老派方法:扫线
因此,任务是绘制二维三角形。 对于有上进心的学生来说,通常需要几个小时,即使他们是糟糕的程序员。 上次我们看到了 Bresenham 的画线算法。 今天的任务是画一个实心三角形。 很有趣,但这个任务并不简单。 我不知道为什么,但我知道这是真的。 我的大多数学生都在为这个简单的任务而苦苦挣扎。
因此,初始代码将如下所示:
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);
}
// ...
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);
像往常一样,GitHub 此处 上提供了适当的提交。代码很简单:我为您的代码的初始调试提供了三个三角形。如果我们在三角形函数内调用line()
,我们将得到三角形的轮廓。如何绘制实心三角形?
一个好的绘制三角形的方法必须具备以下特点:
- 它应该(惊喜!)简单而快速。
- 应该是对称的:图片不应该依赖于传递给绘图函数的顶点顺序。
- 如果两个三角形有两个公共顶点,由于光栅化舍入,它们之间应该没有空洞。
- 我们可以添加更多要求,但让我们来处理这些要求。传统上使用线扫描:
- 按照 y 坐标对三角形的顶点进行排序;
2.同时对三角形的左右边进行光栅化;
3.在左右边界点之间画一条水平线段。
此时我的学生开始不知所措:哪一部分是左边的,哪一部分是正确的?此外,三角形有三个部分…通常,在介绍完这个后,我会让我的学生离开大约一个小时:再一次,阅读我的代码比将自己的代码与我的代码进行比较的价值要小得多。
【一小时过去了】
我如何画一个三角形?再一次,如果你有更好的方法,我很乐意采纳。让我们假设我们有三角形的三个点:t0、t1、t2,它们按 y 坐标升序排列。那么,边界A在t0和t2之间,边界B在t0和t1之间,然后在t1和t2之间。
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) {
// sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!)
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(t1, t2);
line(t0, t1, image, green);
line(t1, t2, image, green);
line(t2, t0, image, red);
}
这里边界A是红色,边界B是绿色。
不幸的是,边界 B 由两部分组成。 让我们通过水平切割来绘制三角形的下半部分:
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) {
// sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!)
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(t1, t2);
int total_height = t2.y-t0.y;
for (int y=t0.y; y<=t1.y; y++) {
int segment_height = t1.y-t0.y+1;
float alpha = (float)(y-t0.y)/total_height;
float beta = (float)(y-t0.y)/segment_height; // be careful with divisions by zero
Vec2i A = t0 + (t2-t0)*alpha;
Vec2i B = t0 + (t1-t0)*beta;
image.set(A.x, y, red);
image.set(B.x, y, green);
}
}
请注意,这些段不是连续的。 上次我们画直线时,我们很难获得连续的线段,在这里我没有费心旋转图像(还记得 xy 交换吗?)。 为什么? 我们向后填充三角形,这就是原因。 如果我们用水平线连接相应的点对,间隙就会消失:
现在,让我们绘制三角形的第二个(上)半部分。 我们可以通过添加第二个循环来做到这一点:
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) {
// sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!)
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(t1, t2);
int total_height = t2.y-t0.y;
for (int y=t0.y; y<=t1.y; y++) {
int segment_height = t1.y-t0.y+1;
float alpha = (float)(y-t0.y)/total_height;
float beta = (float)(y-t0.y)/segment_height; // be careful with divisions by zero
Vec2i A = t0 + (t2-t0)*alpha;
Vec2i B = t0 + (t1-t0)*beta;
if (A.x>B.x) std::swap(A, B);
for (int j=A.x; j<=B.x; j++) {
image.set(j, y, color); // attention, due to int casts t0.y+i != A.y
}
}
for (int y=t1.y; y<=t2.y; y++) {
int segment_height = t2.y-t1.y+1;
float alpha = (float)(y-t0.y)/total_height;
float beta = (float)(y-t1.y)/segment_height; // be careful with divisions by zero
Vec2i A = t0 + (t2-t0)*alpha;
Vec2i B = t1 + (t2-t1)*beta;
if (A.x>B.x) std::swap(A, B);
for (int j=A.x; j<=B.x; j++) {
image.set(j, y, color); // attention, due to int casts t0.y+i != A.y
}
}
}
这可能就足够了,但我不喜欢看到两次相同的代码。 这就是为什么我们将使它的可读性降低一点,但更便于修改/维护:
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) {
if (t0.y==t1.y && t0.y==t2.y) return; // I dont care about degenerate triangles
// sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!)
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(t1, t2);
int total_height = t2.y-t0.y;
for (int i=0; i<total_height; i++) {
bool second_half = i>t1.y-t0.y || t1.y==t0.y;
int segment_height = second_half ? t2.y-t1.y : t1.y-t0.y;
float alpha = (float)i/total_height;
float beta = (float)(i-(second_half ? t1.y-t0.y : 0))/segment_height; // be careful: with above conditions no division by zero here
Vec2i A = t0 + (t2-t0)*alpha;
Vec2i B = second_half ? t1 + (t2-t1)*beta : t0 + (t1-t0)*beta;
if (A.x>B.x) std::swap(A, B);
for (int j=A.x; j<=B.x; j++) {
image.set(j, t0.y+i, color); // attention, due to int casts t0.y+i != A.y
}
}
}
这是提交 用于绘制二维三角形。
3 我的代码采用的方法
虽然不是很复杂,但行扫描的源代码有点混乱。 此外,它确实是一种为单线程 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);
}
}
}
你喜欢它?我愿意。找到一个边界框真的很容易。检查一个点是否属于二维三角形(或任何凸多边形)当然没有问题。
题外话:如果我必须实现一些代码来检查一个点是否属于多边形,并且这个程序将在一个平面上运行,我将永远上不了这个平面。事实证明,可靠地解决这个问题是一项非常困难的任务。但在这里我们只是绘制像素。我对这种情形是没问题。
这个伪代码还有一个我喜欢的地方:编程新手会热情地接受它,更有经验的程序员经常会笑:“什么白痴写的?”。计算机图形编程专家会耸耸肩说:“好吧,这就是现实生活中的工作方式”。数千个线程中的大规模并行计算(我在这里谈论的是普通消费者计算机)改变了思维方式。
好的,让我们开始吧:首先我们需要知道 重心坐标 是什么。给定一个二维三角形 ABC 和一个点 P,它们都在旧的良好笛卡尔坐标 (xy) 中。我们的目标是找到点 P 相对于三角形 ABC 的重心坐标。这意味着我们寻找三个数字 (1 − u − v,u,v) 以便我们可以找到点 P 如下:
这意味着我们正在寻找一个与 (ABx,ACx,PAx) 和 (ABy,ACy,PAy) 正交的向量 (u,v,1) 同时! 我希望你能看到 我朝向哪里。 这是一个小提示:要找到平面中两条直线的交点(这正是我们在这里所做的),计算一个叉积就足够了。 顺便测试一下:我们如何找到通过两个给定点的直线的方程?
因此,让我们编写新的光栅化例程:我们遍历给定三角形的边界框的所有像素。 对于每个像素,我们计算其重心坐标。 如果它至少有一个负分量,则该像素在三角形之外。 可能直接看程序更清楚:
#include <vector>
#include <iostream>
#include "geometry.h"
#include "tgaimage.h"
const int width = 200;
const int height = 200;
Vec3f barycentric(Vec2i *pts, Vec2i P) {
Vec3f u = cross(Vec3f(pts[2][0]-pts[0][0], pts[1][0]-pts[0][0], pts[0][0]-P[0]), Vec3f(pts[2][1]-pts[0][1], pts[1][1]-pts[0][1], pts[0][1]-P[1]));
/* `pts` and `P` has integer value as coordinates
so `abs(u[2])` < 1 means `u[2]` is 0, that means
triangle is degenerate, in this case return something with negative coordinates */
if (std::abs(u[2])<1) return Vec3f(-1,1,1);
return Vec3f(1.f-(u.x+u.y)/u.z, u.y/u.z, u.x/u.z);
}
void triangle(Vec2i *pts, TGAImage &image, TGAColor color) {
Vec2i bboxmin(image.get_width()-1, image.get_height()-1);
Vec2i bboxmax(0, 0);
Vec2i clamp(image.get_width()-1, image.get_height()-1);
for (int i=0; i<3; i++) {
for (int j=0; j<2; j++) {
bboxmin[j] = std::max(0, std::min(bboxmin[j], pts[i][j]));
bboxmax[j] = std::min(clamp[j], std::max(bboxmax[j], pts[i][j]));
}
}
Vec2i P;
for (P.x=bboxmin.x; P.x<=bboxmax.x; P.x++) {
for (P.y=bboxmin.y; P.y<=bboxmax.y; P.y++) {
Vec3f bc_screen = barycentric(pts, P);
if (bc_screen.x<0 || bc_screen.y<0 || bc_screen.z<0) continue;
image.set(P.x, P.y, color);
}
}
}
int main(int argc, char** argv) {
TGAImage frame(200, 200, TGAImage::RGB);
Vec2i pts[3] = {Vec2i(10,10), Vec2i(100, 30), Vec2i(190, 160)};
triangle(pts, frame, TGAColor(255, 0, 0));
frame.flip_vertically(); // to place the origin in the bottom left corner of the image
frame.write_tga_file("framebuffer.tga");
return 0;
}
barycentric() 函数计算给定三角形中点 P 的坐标,我们已经看到了细节。 现在让我们看看 triangle() 函数是如何工作的。 首先,它计算一个边界框,它由两点描述:左下角和右上角。 为了找到这些角,我们遍历三角形的顶点并选择最小/最大坐标。 我还添加了带有屏幕矩形的边界框的剪辑,以节省屏幕外三角形的 CPU 时间。 恭喜你,你知道怎么画三角形了!
4 平面着色渲染
我们已经知道如何用空三角形绘制模型。 让我们用随机颜色填充它们。 这将帮助我们了解我们对三角形填充的编码情况。 这是代码:
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));
}
很简单:就像以前一样,我们遍历所有三角形,将世界坐标转换为屏幕坐标并绘制三角形。 我将在我的后续文章中提供各种坐标系的详细说明。 当前图片如下所示:
让我们摆脱这些小丑颜色并添加一些照明。 上尉明显:“在相同的光强度下,多边形在与光方向正交时被照亮最亮。”
让我们比较一下:
如果多边形平行于光的矢量,我们得到零照明。 解释一下:照明强度等于光向量和给定三角形的法线的标量积。 三角形的法线可以简单地计算为其两侧的叉积。
作为旁注,在本课程中,我们将对颜色进行线性计算。 然而,(128,128,128) 颜色的亮度没有 (255, 255, 255) 一半。 我们将忽略伽马校正并容忍颜色亮度的不正确。
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));
}
}
但点积可能为负。 这是什么意思? 这意味着光线来自多边形的后面。 如果场景建模良好(通常是这种情况),我们可以简单地丢弃这个三角形。 这使我们能够快速删除一些不可见的三角形。 它被称为背面剔除。
请注意,嘴巴的内腔绘制在嘴唇的顶部。 那是因为我们对不可见三角形进行了脏剪裁:它仅适用于凸面形状。 下次我们对 z 缓冲区进行编码时,我们将摆脱这个工件。
这里是 渲染的当前版本。 你有没有发现我的脸的图像更详细? 好吧,我有点作弊:其中有 25 万个三角形,而这个人造头部模型中大约有 1000 个三角形。 但是我的脸确实是用上面的代码渲染的。 我向您保证,在接下来的文章中,我们将为此图像添加更多细节。