TinyRenderer学习(1) —— 线框渲染

112 阅读7分钟

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++ 能力
  • 与图形学本身的核心知识关系不大,更适合在日后的架构优化中细讲