TinyRenderer学习(2)—— 背面剔除和简单光照效果

117 阅读7分钟

TinyRenderer学习(2)

本文对应TinyRenderer Lesson2。 上节课我们学会了如何绘制一条线,本节课将会拓展到绘制一个面。

此外,为了提升渲染性能并确保视觉正确性,我们将引入一个关键的优化技术:背面剔除(back-face culling) 。由于在观察封闭几何体(如网格模型)时,背向观察者的三角形面是不可见的,因此可以在渲染过程中将其剔除。我们将讨论如何实现这一点。


1. 传统的渲染算法: Line Sweeping

这一节中,我们填充三角形的算法是Line Sweeping。这个算法比较经典,而且简单。本质上是从三角形最下端的顶点开始画线,直到填满整个三角形。

伪代码:

// 输入:三角形顶点 v0, v1, v2,已按 y 从小到大排序(v0.y <= v1.y <= v2.y)
// 我们需要把填充分为上下两个三角形
for (int y = v0.y; y <= v1.y; ++y) {
  // 对于y的坐标为y的情况,求出交点(x1, y) (x2, y)
  x1 = calculate_x1(v0, v1, v2, y);
  x2 = calculate_x2(v0, v1, v2, y);
  draw(x1, y, x2, y);
}
​
for (int y = v1.y; y <= v2.y; ++y) {
  // 做相同的事情
}

思路非常直观,用代码实现的时候需要考虑浮点数和整数类型的转换。

以下是我写的第一版的代码,不做任何代码的优化,最朴素的实现方法。

void triangle(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage &image, const TGAColor& color) {
    // // v2.y >= v1.y >= v0.y
    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);
  
    // 渲染三角形的边
    draw(v0, v1, image, color);
    draw(v0, v2, image, color);
    draw(v1, v2, image, color);
​
    int total_height = v2.y - v0.y;
  
    // 相似三角形
    for (int y = v0.y; y <= v1.y; ++y) {
        float dx1 = (y - v0.y) / (float)seg_height * (v1.x - v0.x);
        int x1 = v0.x + dx1;
​
        float dx2 = (y - v0.y) / (float)total_height * (v2.x - v0.x);
        int x2 = v0.x + dx2;
​
        draw(x1, y, x2, y, image, color);
    }
​
    for (int y = v1.y; y <= v2.y; ++y) {
        seg_height = v2.y - v1.y;
        float dx1 = (v2.y - y) / (float)seg_height * (v1.x - v2.x);
        int x1 = v2.x + dx1;
​
        float dx2 = (v2.y - y) / (float)total_height * (v0.x - v2.x);
        int x2 = v2.x + dx2;
​
        draw(x1, y, x2, y, image, color);
    }
}

这个写法是非常标量的,但其实cpp完全支持更向量的写法。

可以参考TinyRenderer作者写的代码,直接求出了点,而不是点的横坐标和纵坐标:

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; 
        draw(A, B, image, color);
    } 
    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; 
        draw(A, B, image, color);
    } 
}

代码变的更优雅了,但是还不够。

两个for循环处理的是类似的逻辑,我们要考虑合并成一个for循环。如果循环变量是y,这段代码还是比较好写的,就是在for里面做一些判断。

但是TinyRenderer的作者不走寻常路,把循环变量换成了

    for (int i=0; i<total_height; i++)

所以他最后写出了这样的代码:

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;
        // be careful: with above conditions no division by zero here 
        float beta  = (float)(i-(second_half ? t1.y-t0.y : 0))/segment_height; 
        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 
        } 
    } 
}

以下是作者这一段代码的评价原话:

This could be enough(这里说的是还没有写到一个for循环的代码), but I dislike to see the same code twice. That is why we will make it a bit less readable, but more handy for modifications/maintaining:

感兴趣可以看下这段代码的实现,和渲染没有直接关系,所以我就不赏析这段似乎很优雅的代码了。

2. 现代渲染的原理真的是Line Sweeping吗?

事实上现在的GPU最擅长的就是并行计算,这样的渲染算法不太适合并行计算的GPU。我们有实现更简单、更容易并行化、更好理解的代码。

算法大致思路是: 对于一个三角形,我们列举他的包围盒里的所有的点,判断是否在三角形内,如果在,就进行渲染。

看起来非常简单粗暴,但是对于并行的GPU,这种非常适合并行的逻辑就是最优的解法,这也是现代渲染普遍在使用的算法。

这是TinyRenderer作者给出的伪代码:

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); 
        } 
    } 
}

所以重点变成了,如何快速判断一个点是否在三角形内

这个问题在数学上有相当完备的解法,但是在计算机世界里没有那么轻易,甚至可以说是不够可靠,以下是作者原话:

If I have to implement some code to check whether a point belongs to a polygon, and this program will run on a plane, I will never get on this plane. Turns out, it is a surprisingly difficult task to solve this problem reliably. But here we just painting pixels. I am okay with that.*

在数学世界,我们能对任何一个情景给出一个绝对清晰的答案。比如一个点在三角形内,在三角形外,或者在三角形上。

我们绝对不会对一个点存有疑虑,无论他多接近三角形的边,只要不在,我们就可以肯定地说,他不在三角形的边上。

但是在计算机世界,或者说是物理和现实世界里,很多时候要考虑到精度。精度不同,一个问题的答案就可能不同。所以我们无法在代码上给出一个绝对正确的算法来判断一个点是否在三角形内。但我们可以无限逼近正确答案。而在渲染的世界里,偶尔一两个点是否被渲染无伤大雅,所以没有太大问题。我们能在O(1)复杂度内比较严谨的解决这个问题。

判断一个点是否在三角形内算法可参考:这里

这就是现代GPU渲染三角形机制的全部了吗?

事实上,我们还有更适合GPU判断一个点是否在三角形内的算法,更适合拓展和并行的算法:边界函数算法

这种算法和重心算法是同源的,主要区别在于这个算法更适合计算机计算,更符合计算机的思维。

3. Back-face Culling & Flat Shading

模型是三维的,当我们渲染到屏幕上的时候,实际上成了二维的。所以我们真正能看见的不是模型的全貌,我们有一个视角,这个视角决定了我们看到的内容。

我们在编写渲染的算法的时候,如果全部渲染上去,会占用大量的资源,而且视觉效果会很错乱。为了避免这一点,我们引入了背面剔除技术。

这个技术的本质在于,会根据我们的视角来判断某个三角形是否需要渲染,性能上有优化(渲染需要做的工作变少了),视觉效果变好了(更接近真实世界的观察事物的逻辑)。

对于一个三角形,我们只需要求出它的法线和光线视角的点积,为正就可以进行渲染。

可参考我最后的代码:

void flat_render(TGAImage &image, ObjModel &obj_model) {
    Vec3f light_dir = Vec3f(0,0,-1); // define light_dir
    for (const auto& face : obj_model.faces) {
        //  渲染一个face
        std::vector<Vec2i> once;
        std::vector<Vec3f> world_coords;
        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;
            float z = obj_model.vs[v_idx].z;
            // 计算点积我们需要三个坐标
            world_coords.emplace_back(x, y, z);
            // 渲染在一个平面内,我们只需要x和y
            once.emplace_back(map2window(x, image.get_height()), map2window(y, image.get_width()));
        }
        // 求点积和单位化
        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(once[0], once[1], once[2], image, TGAColor(intensity*255, intensity*255, intensity*255, 255));
    }
}