[GAMES101/CG] 作业1-透视投影 框架解析手记


Abstract

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

History:

此文章由于存在过一些重大逻辑错误,经过了两次修改,现在仍在勘误中


我们口头模拟一下作业1的绘制过程:

main函数

int main(int argc, const char** argv){
    float angle = 0;
    bool command_line = false;
    std::string filename = "output.png";

    if (argc >= 3) {
        command_line = true;
        angle = std::stof(argv[2]); // -r by default
        if (argc == 4) {
            filename = std::string(argv[3]);
        }
        else
            return 0;
    }

    // 初始化光栅化器的实例r,且定义显示大小为700*700
    rst::rasterizer r(700, 700);

    // 定义摄影机距离为z轴正向10个单位
    Eigen::Vector3f eye_pos = {0, 0, 10};

    // 硬编码定义三角形三个顶点的坐标
    std::vector pos{{2, 0, -2}, {0, 2, -2}, {-2, 0, -2}};

    // 三角形的三个顶点顺序
    std::vector ind{{0, 1, 2}};

    // 向光栅化器传入坐标vector
    auto pos_id = r.load_positions(pos);

    // 向光栅化器传入顶点索引vector,写过OpenGL的应该对这个有印象
    auto ind_id = r.load_indices(ind);

    int key = 0;
    int frame_count = 0;

    // 如果用户在命令行参数选择以图片的形式进行输出
    if (command_line) {
        r.clear(rst::Buffers::Color | rst::Buffers::Depth);

        r.set_model(get_model_matrix(angle));
        r.set_view(get_view_matrix(eye_pos));
        r.set_projection(get_projection_matrix(45, 1, 0.1, 100));

        r.draw(pos_id, ind_id, rst::Primitive::Triangle);
        cv::Mat image(700, 700, CV_32FC3, r.frame_buffer().data());
        image.convertTo(image, CV_8UC3, 1.0f);

        cv::imwrite(filename, image);

        return 0;
    }

    // 在每次绘制循环中,若接收到的键盘输入不为esc
    while (key != 27) {
        // 在本次绘制开头,光栅化器对内部的帧缓存以及深度缓存进行初始化操作
        r.clear(rst::Buffers::Color | rst::Buffers::Depth);
        ...
    }

    return 0;
}

跳转到rasterizer的clear函数看看是如何实现的:

rst::rasterizer::clear(rst::Buffers buff)

注意到clear的参数类型是rst::Buffers,先看看其位于rasterizer.hpp的声明:

rst::Buffers
enum class Buffers
{
    Color = 1,
    Depth = 2
};

可看出,Buffers是一个强类型枚举,它区分了程序欲刷新的缓冲为帧缓存(颜色缓存)还是深度缓存。

现在回到clear函数,可见内部采用了掩码操作:

void rst::rasterizer::clear(rst::Buffers buff)
{
    // 若类型为rst::Buffers::Color,则将帧缓存的颜色刷新为RGB(0, 0, 0)
    if ((buff & rst::Buffers::Color) == rst::Buffers::Color)
    {
        std::fill(frame_buf.begin(), frame_buf.end(), Eigen::Vector3f{0, 0, 0});
    }
    // 若类型为rst::Buffers::Depth,则将深度缓存的所有深度值刷新为无限大(远)
    if ((buff & rst::Buffers::Depth) == rst::Buffers::Depth)
    {
        std::fill(depth_buf.begin(), depth_buf.end(), std::numeric_limits::infinity());
    }
}

回到之前的while循环:

while (key != 27) {
    // 在本次帧绘制开头,光栅化器对内部的帧缓存以及深度缓存进行初始化操作
    r.clear(rst::Buffers::Color | rst::Buffers::Depth);

    /* rasterizer内部有三个类型为Eigen::Matrix4f的变换矩阵,
       我们通过在main函数中定义的三个函数来构造并传递这三个矩阵*/
    r.set_model(get_model_matrix(angle));
    r.set_view(get_view_matrix(eye_pos));
    r.set_projection(get_projection_matrix(45, 1, 0.1, 50));
    ...
}

先跳出循环,看看main函数中的变换矩阵是如何构造的。

get_view_matrix

// 传入摄影机坐标
Eigen::Matrix4f get_view_matrix(Eigen::Vector3f eye_pos)
{
    // 初始化一个单位矩阵view
    Eigen::Matrix4f view = Eigen::Matrix4f::Identity();

    /* 定义一个变换矩阵,其变换操作为,将摄影机移(Transition)至
       右手系坐标原点(0, 0, 0), 在最终渲染时,坐标空间内的所有物
       件均会同摄影机一起相对移动*/
    Eigen::Matrix4f translate;
    translate << 1, 0, 0, -eye_pos[0], 
                 0, 1, 0, -eye_pos[1], 
                 0, 0, 1, -eye_pos[2], 
                 0, 0, 0, 1;

    view = translate * view;

    return view;
}

get_model_matrix

// 传入一个旋转角度
Eigen::Matrix4f get_model_matrix(float rotation_angle)
{
    // 初始化一个单位矩阵model
    Eigen::Matrix4f model = Eigen::Matrix4f::Identity();

    // 定一个一个变换矩阵translate
    Eigen::Matrix4f translate;

    // math.h定义的三角函数均采用弧度制,这里先将旋转角度转换一下
    double radian = rotation_angle / 180.0 * MY_PI;

    translate << cos(radian), -sin(radian), 0, 0,
                 sin(radian),  cos(radian), 0, 0,
                 0, 0, 1, 0,
                 0, 0, 0, 1;

    model = translate * model;

    return model;
}

get_projection_matrix

/* eye_fov: 视场角(Field of View),aspect_ratio:纵横比
   zNear:视锥Frustum的Near面与摄影机的单位距离,zNear:同理为Far面的单位距离*/
Eigen::Matrix4f get_projection_matrix(float eye_fov, float aspect_ratio,
                                      float zNear, float zFar)
{
    // 初始化一个单位矩阵projection
    Eigen::Matrix4f projection = Eigen::Matrix4f::Identity();

    /* 初始化一个单位矩阵persp2ortho,其作用为将透视点映射为适合
       进行正交投影的点*/
    Eigen::Matrix4f persp2ortho = Eigen::Matrix4f::Identity();
    persp2ortho << zNear, 0, 0, 0,
            0, zNear, 0, 0,
            0, 0, zNear + zFar, -zNear * zFar,
            0, 0, 1, 0;

    double halfEyeRadian = eye_fov * MY_PI / 2 / 180.0;
    double top = zNear * tan(halfEyeRadian);
    double bottom = -top;
    double right = top * aspect_ratio;
    double left = -right;

    Eigen::Matrix4f orthoScale = Eigen::Matrix4f::Identity();
    orthoScale << 2 / (right - left), 0, 0, 0,
            0, 2 / (top - bottom), 0, 0,
            0, 0, 2 / (zNear - zFar), 0,
            0, 0, 0, 1;

    Eigen::Matrix4f orthoTrans = Eigen::Matrix4f::Identity();
    orthoTrans << 1, 0, 0, -(right + left) / 2,
            0, 1, 0, -(top + bottom) / 2,
            0, 0, 1, -(zNear + zFar) / 2,
            0, 0, 0, 1;

    Eigen::Matrix4f matrixOrtho = orthoScale * orthoTrans;

    projection = matrixOrtho * persp2ortho;

    return projection;
}

现在来看看透视投影的操作过程。

GAMES101课程框架定义的世界坐标系和OpenGL相同。初始状态下,作业1的摄像机位置以及三角形顶点表示为上图

  • 首先进行Model Transform模型变换,代码中规定\(angle = 0\),即不对三角形进行旋转操作。

  • 接着进行View Transform(在虎书中叫做Camera Transform),这个变换会将摄像机移动到世界原点,同时会应用到世界中的所有对象(这里为三角形),效果即为三角形和摄像机之间保持相对静止地进行了一次移动,在这里,我们的三角形三个顶点被变换为\((2, 0, -12), (0, 2, -12), (-2, 0, -12)\)

现在,规定一个空间:

  • 它的前后由Near面和Far面所限制,这个空间叫做View Space(虎书中叫做Camera Space),对于其形状命名为Frustum视锥,见下图灰色部分体积。

  • 超出View Space的对象会被剔除或裁剪。在作业1中,我们的三角形正确地坐落在View Space中。

  • 摄影机(原点)与far面上下两条边的中点分别连线,两条连线之间的夹角即为视场角(FOV, Field of View)。

  • Near面是实际的物理投影面,也即,处于View Space中的对象经过透视变换会成为Near面上的二维投影。

  • 对象仅在处于View Space时,其顶点z值才具有实际的、表示“位置”的物理意义。投影变换完成后,其z值不再具有实际物理意义,而仅用于深度测试。

好了,到这里我们得到了View Space,而透视投影变换的目标是将View Space中的所有点变换到Canonical View Volume(CVV)中(虎书141页),CVV是一个重心与原点重合,边长为2的正方体:

为了得到这个目标,首先将View Space内所有点经由透视投影按正确的比例映射到near面上,图形学通用做法是将此视锥内的所有点按照比例通过透视投影压缩成一个正交立方体,这个步骤能将三角形正好压缩成与Near面上的小三角形投影等大小:

现在,我们在View Space中任意取一点,它在经过变换后会在Near面上有一个投影点,我们通过计算它们的关系可以得出一个能求得正确变换比例的矩阵,如下图:

考虑这两个点的x值如何建立一个变换关系:

  • 设Near面与原点的距离为其缩写n,设在View Space中取的任意点距原点z。

  • 可看出灰色区域有两个相似三角形,则可以得出关系式 \(\frac{x^{'}}{x} = \frac{n}{z}\),也即 \(x^{'} = \frac{n}{z} * x\)

考虑两点的y值的变换关系:

  • 可看出灰色区域有两个相似三角形,则可以得出关系式 \(\frac{y^{'}}{y} = \frac{n}{z}\),也即 \(y^{'} = \frac{n}{z} * y\)

在数学上表示的话:

  • 即同理地对于View Space内任意一点 \(\left(\begin{array}{c}x\\ y\\ z\\ 1\end{array}\right)\),其通过比例运算后,会变为 \(\left(\begin{array}{c}nx/z\\ ny/z\\ unknown\\ 1\end{array}\right)\)(目前并不清楚z值的变换关系)。

  • 又由齐次坐标的性质(对每个坐标分量乘以一个相同的数,坐标与原坐标相同),我们对每个坐标分量同时乘以\(z\)\(\left(\begin{array}{c}nx\\ ny\\ unknown\\ z\end{array}\right)\)

而我们就是要构建一个矩阵M,使得对于视锥内的所有点(x,y,z),有 \(M^{(4 x 4)}_{persp->ortho}\left(\begin{array}{c}x\\ y\\ z\\ 1\end{array}\right)=\left(\begin{array}{c}nx\\ ny\\ unknown\\ z\end{array}\right)\)

由于矩阵M为一个4*4矩阵,且根据此等式以及矩阵和向量的乘法,容易推出M除了第三行之外的数字:

\(M^{(4 x 4)}_{persp->ortho}=\left(\begin{array}{cccc}n & 0 & 0 & 0\\ 0 & n & 0 & 0\\ ? & ? & ? & ?\\ 0 & 0 & 1 & 0\end{array}\right)\)

只需要将第三行推出来,M就完美了。现在我们还有两个条件可用:

1.在投影变换的过程中,位于near面上的任何点都不会改变

near面的z值为n,所以任意near上的一点可表示为\(\left(\begin{array}{c}x\\ y\\ n\\ 1\end{array}\right)\)。由于near上的点经过投影变换不会有任何改变,所以会有这样的等式\(M ^{(4 x 4)}_{persp->ortho}\left(\begin{array}{c}x\\ y\\ n\\ 1\end{array}\right)=\left(\begin{array}{c}x\\ y\\ n\\ 1\end{array}\right)\),为了方便计算,将等式右边的点乘以n,变为:\(M ^{(4 x 4)}_{persp->ortho}\left(\begin{array}{c}x\\ y\\ n\\ 1\end{array}\right)=\left(\begin{array}{c}nx\\ ny\\ n^{2}\\ n\end{array}\right)\)

所以根据此等式,容易推出M的第三行为(0, 0, A, B),也即 \(\left(\begin{array}{cccc}0 & 0 & A & B\end{array}\right)\left(\begin{array}{c}x\\ y\\ n\\ 1\end{array}\right)=n^{2}\),进而得到方程 \((1) An + B = n^{2}\)

2.在投影变换的过程中,位于far面上的任何点的z值均不会改变(因为far面本身不会移动)

假设far面的z值为f,由此条件,同理可得到方程 \((2) Af + B = f^{2}\)

由式子(1)(2)解出 \(A = n + f\) 以及 \(B = -nf\)

到此,我们成功得到矩阵M:\(M^{(4 x 4)}_{persp->ortho}=\left(\begin{array}{cccc}n & 0 & 0 & 0\\ 0 & n & 0 & 0\\ 0 & 0 & n + f & -nf\\ 0 & 0 & 1 & 0\end{array}\right)\)

之后的工作便是将这个正交立方体压缩为一个CVV:

我们同样根据比例将正交立方体进行压缩、平移。所以分别构造缩放矩阵和平移矩阵:

\(M^{(4 x 4)}_{orthoScale}=\left(\begin{array}{cccc}\frac{2}{r - l} & 0 & 0 & 0\\ 0 & \frac{2}{t - b} & 0 & 0\\ 0 & 0 & \frac{2}{n - f} & 0\\ 0 & 0 & 0 & 1\end{array}\right)\) \(M^{(4 x 4)}_{orthoTrans}=\left(\begin{array}{cccc}1 & 0 & 0 & \frac{-(r + l)}{2}\\ 0 & 1 & 0 & \frac{-(t + b)}{2}\\ 0 & 0 & 0 & \frac{-(n + f)}{2}\\ 0 & 0 & 0 & 1\end{array}\right)\)

但由于实际的函数参数并未包含t、b、l、r,所以我们需要根据给定的几个参数将它们算出来。

代码中的aspect_ratio即为纵横比(长宽比),所以参考上图和代码,可以发现其实仅做了一些简单的几何运算便算出了我们需要的参数。

这样一来,我们便完成了透视投影变换矩阵的构造。现在回到循环:

while (key != 27) {
    r.clear(rst::Buffers::Color | rst::Buffers::Depth);

    r.set_model(get_model_matrix(angle));
    r.set_view(get_view_matrix(eye_pos));
    r.set_projection(get_projection_matrix(45, 1, 0.1, 50));

    r.draw(pos_id, ind_id, rst::Primitive::Triangle);
    ...
}

现在看看rasterizer的draw函数做了些什么。回顾下之前的代码:

auto pos_id = r.load_positions(pos);
auto ind_id = r.load_indices(ind);

向rasterizer的这两个函数分别传入顶点坐标以及顶点索引后,会分别返回一个"id"。为了弄清楚"id"是做什么的,先看看"load"函数的内部:

rasterizer.cpp

rst::pos_buf_id & rst::ind_buf_id

注意到函数load_positions以及load_indices的返回值类型分别为rst::pos_buf_id以及rst::ind_buf_id,这两个类型定义在rasterizer.hpp中:

struct pos_buf_id
{
    int pos_id = 0;
};

struct ind_buf_id
{
    int ind_id = 0;
};

可看到闫教授将两种类型为int的"id"以struct的形式包装起来了,目的应该是区分"id"的类型。

回到两个函数:

rst::pos_buf_id rst::rasterizer::load_positions(const std::vector &positions)
{
    auto id = get_next_id();
    pos_buf.emplace(id, positions);

    return {id};
}

rst::ind_buf_id rst::rasterizer::load_indices(const std::vector &indices)
{
    auto id = get_next_id();
    ind_buf.emplace(id, indices);

    return {id};
}
  • 函数内的get_next_id()维护了rasterizer的一个内部成员countcount初始化值为0,每次调用get_next_id(),它会将count值加1并返回。

  • 而函数内的pos_buf以及ind_buf均为rasterizer的内部变量成员,且类型均为std::map

  • 也就是说,在调用一个"load"函数时,函数会生成一个新的"id"值作为对应map的一个key,并将键值对{id, 顶点坐标vector / 顶点索引vector}插入对应map中。

最后,这两个"load"函数分别返回生成的键值给调用者,供draw函数使用(根据闫教授的代码注释,这样设计的原因是,可以保证调用者能拿到正确的key,也就是"id",来取得对应的顶点坐标vector / 顶点索引vector,避免调用者搞错)。

注意,每次生成的buf_id以及ind_id应该是对应的:

知道两个"load"函数的返回值意义后,回到draw函数:

draw

// 向draw函数传入之前生成的“顶点坐标缓冲id”以及“顶点索引缓冲id”
void rst::rasterizer::draw(rst::pos_buf_id pos_buffer, rst::ind_buf_id ind_buffer, rst::Primitive type)
{
    // 当前作业中,代码支持的图元(Primitive)类型仅为rst::Primitive::Triangle,即三角形
    if (type != rst::Primitive::Triangle)
    {
        throw std::runtime_error("Drawing primitives other than triangle is not implemented yet!");
    }

    // 容易得出,auto推导出的类型为std::vector
    // buf取得对应的图元顶点坐标vector
    auto& buf = pos_buf[pos_buffer.pos_id];
    // ind取得对应的图元顶点索引vector
    auto& ind = ind_buf[ind_buffer.ind_id];

    // 下面会解释f1、f2的含义
    float f1 = (100 - 0.1) / 2.0;
    float f2 = (100 + 0.1) / 2.0;

    // 最终的变换矩阵为投影、视图、模型矩阵的点乘
    Eigen::Matrix4f mvp = projection * view * model;

    for (auto& i : ind)
    {
        // 实例化一个Triangle
        Triangle t;
      
        // 构造一个元素为4行向量的数组v,向内插入mvp矩阵对顶点索引对应顶点坐标的变换点
        // 为了和mvp进行运算,将每个顶点坐标转为一个Eigen::Vector4f,并规定w值为1
        Eigen::Vector4f v[] = {
                mvp * to_vec4(buf[i[0]], 1.0f),
                mvp * to_vec4(buf[i[1]], 1.0f),
                mvp * to_vec4(buf[i[2]], 1.0f)
        };

        // 透视除法
        for (auto& vec : v) {
            vec /= vec.w();
        }

        // 视口变换操作
        for (auto & vert : v)
        {
            vert.x() = 0.5*width*(vert.x()+1.0);
            vert.y() = 0.5*height*(vert.y()+1.0);
            vert.z() = vert.z() * f1 + f2;
        }

        // 将变换好的顶点坐标传入三角形实例t
        for (int i = 0; i < 3; ++i)
        {
            t.setVertex(i, v[i].head<3>());
            t.setVertex(i, v[i].head<3>());
            t.setVertex(i, v[i].head<3>());
        }

        // 根据顶点索引设置每个顶点的颜色
        t.setColor(0, 255.0,  0.0,  0.0);
        t.setColor(1, 0.0  ,255.0,  0.0);
        t.setColor(2, 0.0  ,  0.0,255.0);

        rasterize_wireframe(t);
    }
}

视口变换

可以想象z轴正向有一个正交摄影机,其拍摄的正交影像通过视口变换投影拉伸到屏幕空间上。需要注意的是,大多数情况下投影plane的宽高比是与CVV(正方体)的宽高比不同的,因此顶点变换到CVV中后图像可能会发生变形压缩,但只要最后映射的屏幕空间的宽高比与投影plane一致,视口变换必定能将这种错误矫正回来。

视口变换需要注意的点:

视口变换的情况比较特殊,我们考虑一个非特殊变换例子。矩形区域\(A\)的中心为\(O\),上下左右边界分别为\(t、b、l、r\),我们欲将\(A\)中所有点投影到\(B\)区域:

比较符合直觉的方法便是,先将\(A\)区域的中点平移到\(B\)区域的中点,再将长宽拉伸直到与区域\(B\)重合:

平移算法非常简单。首先算出\(A\)区域中点\(o = (\frac{l + r}{2}, \frac{t + b}{2})\)\(B\)区域中点\(o' = (\frac{l' + r'}{2}, \frac{t' + b'}{2})\)

可容易得出对原\(A\)区域所有点进行平移的矩阵:

\(M _{Translation}\left(\begin{array}{cccc}1 & 0 & 0 & \frac{l' + r'}{2} - \frac{l + r}{2}\\ 0 & 1 & 0 & \frac{t' + b'}{2} - \frac{t + b}{2}\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1\end{array}\right)\)

之后我们对\(A\)区域的点采用比例拉伸的方法。\(A\)区域的宽\(width = r - l\),高\(height = t - b\)\(B\)区域的宽\(width' = r' - l'\),高\(height' = t' - b'\)

所以对于原区域内的点,对其\(x\)值进行拉伸的比例因子为\(\frac{width'}{width}\);对其\(y\)值进行拉伸的比例因子为\(\frac{height'}{height}\)

结合平移矩阵,最终得到我们需要的变换矩阵:\(M _{Transform}\left(\begin{array}{cccc}\frac{width'}{width} & 0 & 0 & \frac{l' + r'}{2} - \frac{l + r}{2}\\ 0 & \frac{height'}{height} & 0 & \frac{t' + b'}{2} - \frac{t + b}{2}\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1\end{array}\right)\)


回到视口变换,它特殊在其原区域的中心正好是坐标轴原点\((0,0)\)

经过model、view、projection变换后的顶点的\(x、y、z\)值的范围均为\([-1, 1]\)。先不考虑z值,我们关注如何将顶点的\(x、y值\)\([-1, -1]^{2}\)变换到\([0,width]*[0,height]\),也即:

根据我们上面推导的变换矩阵,现在可以直接将对应值代入:

\(M_{Viewport} = M _{Transform}\left(\begin{array}{cccc}\frac{width}{1-(-1)} & 0 & 0 & \frac{0 + width}{2} - \frac{-1 + 1}{2}\\ 0 & \frac{height}{1-(-1)} & 0 & \frac{height + 0}{2} - \frac{1 + (-1)}{2}\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1\end{array}\right) = \left(\begin{array}{cccc}\frac{width}{2} & 0 & 0 & \frac{width}{2}\\ 0 & \frac{height}{2} & 0 & \frac{height}{2}\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1\end{array}\right)\)

值得一提的是,框架代码进行视口变换的操作并没有使用矩阵运算,而是直接在坐标值上进行了计算,也即将变换矩阵

\(M _{Viewport}\left(\begin{array}{cccc}\frac{width}{2} & 0 & 0 & \frac{width}{2}\\ 0 & \frac{height}{2} & 0 & \frac{height}{2}\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1\end{array}\right)\)

变为vert.x() = 0.5*width*(vert.x()+1.0);这样的操作。

现在回来考虑原顶点的\(z\)值,由于\(z\)也经历了模型、视图、投影的变换过程,所以\(z\)值范围也被缩小到了\([-1, 1]\)范围内,此时变换过的\(z\)值很可能是错误的,因为投影变换是一个非线性变换!。由于\(z\)值会被用于深度测试,所以必须将其变换到原来的近、远面[near, far]范围内。

由main函数的代码可知框架定义的near、far面值为0.1、100:r.set_projection(get_projection_matrix(45, 1, 0.1, 100));

所以我们的任务便是将\(z\)值从\([-1,1]\)重新变换到\([0.1,100]\)中,方法和上面的大同小异,将原范围中心平移到目标范围,再进行缩放:

首先算\([-1, 1]\)的长度:\(width = 1 - (-1) = 2\)\([0.1, 100]\)的长度:\(width' = 100 - 0.1\);再分别算两个范围的中点:\(o = \frac{1 + (-1)}{2}、o' = \frac{(100 + 0.1)}{2}\)

则可得出缩放因子为\(\frac{width'}{width} = \frac{100-0.1}{2}\),平移值为\(o' - o = \frac{(100 + 0.1)}{2} - \frac{1 + (-1)}{2} = \frac{(100 + 0.1)}{2}\)

所以draw函数中的f1和f2就是这么来的,f1代表缩放值,f2代表平移值,它们的分母均为2仅仅是一个巧合;

变换代码也即为vert.z() = vert.z() * f1 + f2;


回到代码中,框架将三个变换好的顶点传入三角形实例,并为每一个顶点设置了一个RGB颜色值,最后调用rasterize_wireframe。从函数名字来看,就能知道它是用来画线条的。

rasterize_wireframe
// 分别对三边进行绘制
void rst::rasterizer::rasterize_wireframe(const Triangle& t)
{
    draw_line(t.c(), t.a());
    draw_line(t.c(), t.b());
    draw_line(t.b(), t.a());
}

框架对于draw_line的实现是采用了经典的Bresenhan两点画线算法。算法每计算出直线上的一个点,框架便调用set_pixel(point, line_color)来进行单个点的着色:

set_pixel
void rst::rasterizer::set_pixel(const Eigen::Vector3f& point, const Eigen::Vector3f& color)
{
    //old index: auto ind = point.y() + point.x() * width;
    if (point.x() < 0 || point.x() >= width ||
        point.y() < 0 || point.y() >= height) return;
    auto ind = (height-point.y())*width + point.x();
    frame_buf[ind] = color;
}

函数首先对坐标值的合法性做一个判断。然后算出当前点的存放索引应该为rasterizer成员frame_buf的哪个位置。为了解释这个索引算法,先看看rasterizer的构造函数:

rasterizer::rasterizerr(int, int)
rst::rasterizer::rasterizer(int w, int h) : width(w), height(h)
{
    frame_buf.resize(w * h);
    depth_buf.resize(w * h);
}

我们看到,光栅化器的构造函数传入实际显示的宽度以及长度,并将内部vector成员frame_buf以及depth_buf的尺寸设置为width * height。

如何解释着色点的索引过程呢?

实际上我们将显式区域的面积进行了离散化,而这个求着色点的过程其实是在对面积进行量化。在代码中,我们将上图的屏幕矩形的离散点从头到尾打散并装入一个一维的vector中,所以这种索引方式无论是在二维表示还是一维表示均是有效的。

需要注意的是,存储帧缓存的Mat数组的寻址起点是左上角,而待光栅化的顶点的寻址起点是左下角。

至此作业1代码的解析就告一段落了,本人水平十分有限,如果有任何疏漏欢迎指正。

CG