1. 前言
最近回学校复习考试,时间比较多,打算学一学手写cpp图形渲染。
目前感觉本质是在代码层面模拟GPU的执行过程,将OBJ渲染成TGA。
请先参考:TinyRenderer第一节 再阅读本文
2. 如何优雅的画一条线
首先我想聊一下TinyRenderer作为一个完全不使用任何Graphic框架的渲染代码实现,他提供给我们的框架是什么?
这看起来很矛盾,但是TinyRenderer力求给我省去学习GPU渲染流程的不必要的过程。
他对TGA这种文件格式做了封装,我们只需要设置某个像素点是什么颜色,他就可以帮我把这个TGA全貌画出来。
这个封装好的类是 TGAImage 我们暂时不需要完整的阅读它,因为它实现的内容和渲染本身关系不是很大。他只是根据类的信息把TGA真的画出来,而不是我们关注的渲染过程。
第一节我们关注的重点是:我们如何对给定的两个点中间高效地渲染出一条尽可能丝滑的线?
在考虑效率的问题前,我们可以先思考如何让线平滑。
在现实世界中,这个问题非常简单以至于根本称不上问题:我们把它连起来不就可以了吗?
但是在计算机世界中,对于一个TGA,像素点不是浮点数,而是确确实实的int类型。我们只能选择尽可能接近的点,而无法像真实世界一样画一条由无数个点组成的线。
我们要让点尽可能密集,那么要考虑到dx和dy,即两个点的横坐标差和竖坐标差,选用大的一边作为点的个数。
在此基础上,我们假设第二个点在第一个点的右上方,我们可以写出这样的代码:
// (x0, y0) 和 (x1, y1)是两个点,必须是整数,所以我们写的算法要基于这一点考虑
void draw(int x0, int y0, int x1, int y1, TGAImage* image, const TGAColor& color) {
if (abs(x0 - x1) >= abs(y0 - y1)) {
// dx的更长,根据x选点
for (int x = x0; x <= x1; ++x) {
int y = y0 + (x - x0) * ((y1 - y0) / (float)(x1 - x0));
image->set(x, y, color);
std::printf("x = %d, y = %d\n", x, y);
}
} else {
// dy更长,根据y选点
for (int y = y0; y <= y1; ++y) {
int x = x0 + (y - y0) * ((x1 - x0) / (float)(y1 - y0));
image->set(x, y, color);
std::printf("x = %d, y = %d\n", x, y);
}
}
}
这份代码为了实现功能大体没有问题,但是我们还有一些未解决的问题。
第一个问题是这两个点的关系是随机的,但是我们假设(x0, y0)在(x1, y1)的左下角。
我们要通过一定的变换来保证这一点,并且我们要加入轴交换来简化我们的分支结构,前方高能!
void draw(int x0, int y0, int x1, int y1, TGAImage* image, const TGAColor& color) {
// 这一部分优化后的代码个人觉得有些抽象,可能是我想想能力不够吧
// 关键在于这一步, 轴交换,这里才会影响后面的image->set
bool is_steep = false;
if (abs(x0 - x1) < abs(y0 - y1)) {
std::swap(x0, y0);
std::swap(x1, y1);
is_steep = true;
}
// 这里交换之后,两个点还是那两个点,所以只做这个操作,image->set的时候不用特殊处理
if (x0 > x1) {
std::swap(x0, x1);
std::swap(y0, y1);
}
for (int x = x0; x <= x1; ++x) {
// 计算比例
float t = (x - x0) / (float)(x1 - x0);
// 等价: int y = y0 + (y1 - y0) * t;
// GPT说图形学更倾向下面这种写法,暂且相信他一次
int y = y0 * (1. - t) + y1 * t;
if (is_steep) {
// 这种情况下是我们交换过主轴的,所以要反过来
image->set(y, x, color);
std::printf("x = %d, y = %d\n", y, x);
} else {
image->set(x, y, color);
std::printf("x = %d, y = %d\n", x, y);
}
}
}
到了这里,虽然代码变得优雅了,但是理解难度大了不少,而且我们的优化还没有做完。
我们可以考虑这里存在的float是否可以完全隐去?对于当前的代码的y的计算方式,无法做到。
但是我们换一种y的获取方式,或许就可以了。
void draw(int x0, int y0, int x1, int y1, TGAImage* image, const TGAColor& color) {
bool is_steep = false;
if (abs(x0 - x1) < abs(y0 - y1)) {
std::swap(x0, y0);
std::swap(x1, y1);
is_steep = true;
}
if (x0 > x1) {
std::swap(x0, x1);
std::swap(y0, y1);
}
float error = 0;
// 每次的错误量
// 这里的float能否做倍数拓展?(float点 1)
float derror = abs((y1 - y0) / float(x1 - x0));
int y = y0;
for (int x = x0; x <= x1; ++x) {
if (is_steep) {
image->set(y, x, color);
std::printf("x = %d, y = %d\n", y, x);
} else {
image->set(x, y, color);
std::printf("x = %d, y = %d\n", x, y);
}
error += derror;
// 错误量累计超过0.5, 就减1
// 这里是重点,能否考虑将这里的0.5f做倍数拓展? (float点 2)
if (error > 0.5f) {
y += (y1 > y0) ? 1 : -1;
error -= 1.0f;
}
}
}
答案是可以,我们对两边乘 2 * (x1 - x0)就做到了, 于是
float derror = abs((y1 - y0) / float(x1 - x0));
变成
int derror = 2 * abs(y1 - y0);
if (error > 0.5f) {
y += (y1 > y0) ? 1 : -1;
error -= 1.0f;
}
变成
if (error > (x1 - x0)) {
y += (y1 > y0) ? 1 : -1;
error -= 2 * (x1 - x0);
}
到此我们就完成了优雅的画一条线
3. 一次真实的渲染任务(Wireframe Rendering)
接下来,我们将使用刚刚实现的draw函数,完成一次基于真实 .obj 模型文件的线框渲染任务。
首先,从 这里 中下载模型文件。注意:只需要下载 .obj 文件本身,不要下载其他代码。
接下来我们要将obj文件的数据读出来,整理我们需要的数据来进行渲染。 对于此次任务,我们只需要关注
// 这是一个顶点数据 (x, y, z)
v -0.609326 -0.569868 -0.41571
和
// 这是一个面数据,由多个索引组组成
// 一个索引组:v_idx/vt_idx/vn_idx
f 24/1/24 25/2/25 26/3/26
我们此次任务只需要关注每个索引组的第一个元素,它代表face的顶点的索引。
对于每个面(face),只提取其顶点索引,获取对应顶点坐标,并将它们连接成线段,从而绘制出整个模型的线框结构(wireframe)。
以下是核心渲染代码:
void wireframe_render(TGAImage &image, ObjModel &obj_model) {
for (const auto& face : obj_model.faces) {
// 渲染一个face
std::vector<Vec2> once;
for (const auto& face_idx: face.indices) {
int v_idx = face_idx.v_idx;
float x = obj_model.vs[v_idx].x;
float y = obj_model.vs[v_idx].y;
once.emplace_back(x, y);
}
for (int i = 0; i < once.size(); ++i) {
draw(once[i], once[(i + 1) % once.size()], image, white);
}
}
}
至此,我们完成了渲染器的第一次“实战”任务:基于 .obj 文件绘制线框模型(Wireframe Rendering) 。
本篇文章的重点并不在于如何解析 .obj 文件、如何组织数据结构,而是聚焦在渲染技术本身,尤其是本节的核心问题:
如何高效而准确地“画一条线”?
为此,我花了较多笔墨解释了代码背后的原理,包括:
- 为什么这样画线是必要的?
- 哪些算法存在效率隐患?
这些思考并不是“炫技”,而是构建一个健壮图形渲染器所必须迈出的第一步。
至于.obj文件的读取、数据结构的设计等内容,我选择在这一节中暂时略过。原因是:
- 它们是每个渲染器作者都必须掌握的基础 C++ 能力
- 与图形学本身的核心知识关系不大,更适合在日后的架构优化中细讲