• Github TinyRenderer渲染器课程实践记录


    Abstract

    上一节:z-buffer & 纹理映射

    从正交投影到透视投影。

    如果你对投影几何没什么概念,可以移步 这里

    Reference :


    在移步到投影几何之前,回忆之前渲染的场景,是基于正交投影计算的。

    也即,导入模型的顶点已经在 ([-1, 1]^{3}) 范围中,只需要将顶点映射至屏幕空间再光栅化即可。

    但对于更真实的情况 --- 人眼或者说相机的实际成像原理为透视投影。


    线性变换

    线性变换的原理

    普通笛卡尔平面上的线性变换可由一个对应的矩阵表示。设有 (P^2) 平面的笛卡尔坐标 ((x, y)),它的线性变换可以写成:

    (left[egin{array}{cc} a & b \ c & d end{array} ight]left[egin{array}{c} x \ y end{array} ight] = left[egin{array}{c} ax + by \ cx + dy end{array} ight])

    最简单的变换由单位矩阵表示:

    (left[egin{array}{cc} 1 & 0 \ 0 & 1 end{array} ight]left[egin{array}{c} x \ y end{array} ight] = left[egin{array}{c} x \ y end{array} ight])

    矩阵左对角线上的系数会对线性空间的基产生缩放作用:

    (left[egin{array}{cc} 1.5 & 0 \ 0 & 1.5 end{array} ight]left[egin{array}{c} x \ y end{array} ight] = left[egin{array}{c} 1.5x \ 1.5y end{array} ight])

    让我们用代码来感受一下线性变换的魅力。这里有另一个"正方体"(之所以打引号,是因为它缺少一个右上角)obj模型,我们将它的正面放在屏幕空间的中心并绘制出来,为了体现“中心”,再画出 (xy) 坐标轴。

    检查一下它的顶点,正好是CVV空间的边界:(你若对CVV没什么概念,请移步 这里 ,在标准图形管线流程中,预备映射到屏幕空间上的顶点是处于一个 ([-1, 1]^3) 的正方体中的,就像本篇开头的那个示意图一样)

    v -1 -1  1
    v  1 -1  1
    v  1  0  1
    v  0  1  1
    v -1  1  1
    v  1  1  0
    v -1 -1 -1
    v  1 -1 -1
    v  1  1 -1
    v -1  1 -1
    

    也就是说,如果将这个"正方体"进行视区变换后,它的边界会正好处于图像边缘,看不太清楚:

    但是若将视区变换修改一下,也即将视区范围缩小一点,就能适合观察了。在我们现在的代码中,视区变换现在由矩阵运算来完成:(若你对视区变换概念不强,请移步此文章的后半部分推导)

    // 将[-1,1]^2中的点变换到以(x,y)为原点,w,h为宽与高的屏幕区域内
    Matrix viewport(int x, int y, int w, int h) {
        Matrix m = Matrix::identity(4);
        m[0][3] = x + w/2.f;
        m[1][3] = y + h/2.f;
        m[2][3] = depth/2.f;
    
        m[0][0] = w/2.f;
        m[1][1] = h/2.f;
        m[2][2] = depth/2.f;
        return m;
    }
    

    当前显示不清楚的视区矩阵调用是这样的:

    Matrix VP = viewport(0, 0, width, height);
    

    也即将 ([-1, -1]^2) 这个正方形范围的中心移至屏幕空间中心,其宽高缩放为和屏幕一样:

    但若将矩阵调用修改一下:

    Matrix VP = viewport(width/4, height/4, width/2, height/2);
    

    视区便会被正确缩小:


    当绘制结果利于观察后,我们便可以尝试一下对“正方体”进行两个线性变换(黄线绘制的为其变换后结果):

    • 缩放1.5倍

    Matrix T = zoom(1.5);

    • 平移并绕z轴旋转

    Matrix T = translation(Vec3f(.33, .5, 0))*rotation_z(cos(10.*M_PI/180.), sin(10.*M_PI/180.));

    • Shear 错切操作

    效果是让图像往某个坐标轴上的方向倾斜。

    (left[egin{array}{cc} 1 & frac{1}{3} \ 0 & 1 end{array} ight]left[egin{array}{c} x \ y end{array} ight] = left[egin{array}{c} x + frac{y}{3} \ y end{array} ight])

    Matrix T = Matrix::identity(4);
    T[0][1] = 0.333;
    


    用透视投影来表示3D空间

    在TinyRender此节,我们使用一种特殊的透视投影,将投影平面设为 (z = 0)

    设投影机坐标 ((0, 0, c)) ,待投影点 (P(x, y, z)) ,投影点 (P^{'}(x^{'}, y^{'}, z^{'}))

    由相似三角形:

    (frac{x}{c - z} = frac{x^{'}}{c}, (1)\\ frac{y}{c - z} = frac{y^{'}}{c}, (2))

    可推出 (x^{'} = frac{x}{1 - frac{z}{c}})(y^{'} = frac{y}{1 - frac{z}{c}})

    那么便可以在将顶点变为屏幕坐标之前为它们乘一个透视变换矩阵:

    (left[egin{array}{cccc}1 & 0 & 0 & 0 \ 0 & 1 & 0 & 0 \ 0 & 0 & 1 & 0 \ 0 & 0 & -frac{1}{c} & 1end{array} ight])

    它正好可以将顶点变成我们推导的 (P^{'}) 那样。

    不过值得注意的是,这种透视投影计算方式,仅考虑了 (z ge 0) 内的顶点,(z lt 0) 的点同样会受到变换矩阵影响,但由于它们根本没在我们数学建模范围内,所以变成什么样根本没人知道。

    但幸好有 z-buffer,(z) 值不受透视变换的影响,所以计算覆盖关系的时候 (z ge 0) 内的顶点 一定能覆盖后面的点,所以出错的点不会被我们看到。


    扫描线算法用来进行 (uv) 插值

    重心坐标配合包围盒来进行插值与光栅化是较为常见的手段,现在介绍用扫描线算法进行插值的思想。

    回忆 三角形光栅化 此章。我们得到三角形的三个顶点后,用扫描线三线性插值法来得到三角形内部的所有点。

    这对于 (uv) 插值同样适用,因为三角形三个顶点同样对应三个 (uv) 坐标,只需为 (uv) 坐标采取同样的采样算法即可。(注意扫描线算法要对三顶点 (y) 的顺序进行排序,对它们对应的 (uv) 同样需要)

    void triangle(Vec3i t0, Vec3i t1, Vec3i t2, Vec2i uv0, Vec2i uv1, Vec2i uv2, TGAImage &image, float intensity, int *zbuffer) {
        if (t0.y==t1.y && t0.y==t2.y) return; // 处理三角形退化情况
        if (t0.y>t1.y) { std::swap(t0, t1); std::swap(uv0, uv1); }
        if (t0.y>t2.y) { std::swap(t0, t2); std::swap(uv0, uv2); }
        if (t1.y>t2.y) { std::swap(t1, t2); std::swap(uv1, uv2); }
    
        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; // 第二次线性插值
            Vec3i A   =               t0  + Vec3f(t2-t0  )*alpha;
            Vec3i B   = second_half ? t1  + Vec3f(t2-t1  )*beta : t0  + Vec3f(t1-t0  )*beta;
            Vec2i uvA =               uv0 +      (uv2-uv0)*alpha;
            Vec2i uvB = second_half ? uv1 +      (uv2-uv1)*beta : uv0 +      (uv1-uv0)*beta;
            if (A.x>B.x) { std::swap(A, B); std::swap(uvA, uvB); }
            for (int j=A.x; j<=B.x; j++) {
                float phi = B.x==A.x ? 1. : (float)(j-A.x)/(float)(B.x-A.x); // 第三次线性插值  
                Vec3i   P = Vec3f(A) + Vec3f(B-A)*phi;
                Vec2i uvP =     uvA +   (uvB-uvA)*phi;
                int idx = P.x+P.y*width;
                if (zbuffer[idx]<P.z) {
                    zbuffer[idx] = P.z;
                    TGAColor color = model->diffuse(uvP);
                    image.set(P.x, P.y, TGAColor(color.r*intensity, color.g*intensity, color.b*intensity));
                }
            }
        }
    }
    

    最后,应用了透视投影法后,可以得到一个较为真实的渲染视角

    输出相应的 z-buffer :

    可以看到,离屏幕较近的部分越亮,离屏幕越远的部分越暗,这符合深度缓存的原理。

  • 相关阅读:
    关于 log4j.additivity
    JDK8新特性:使用Optional:解决NPE问题的更干净的写法
    异常处理和日志输出使用小结
    搭建DNS服务器
    git 使用技巧
    mysql
    linux学习记录
    nginx解析
    node npm pm2命令简析
    jenkins使用简析
  • 原文地址:https://www.cnblogs.com/1Kasshole/p/14515994.html
Copyright © 2020-2023  润新知