前言

GAMES101作业系列为GAMES101作业记录(包含作业1-8),文章中会解释作业内容并附上代码,同时对代码框架进行简要说明。
已完成的代码可在我的GitHubGAMES101作业代码仓库获取。

GAMES101课程相关基础知识可参看本博客GAMES101知识梳理系列


作业描述

任务说明

本次作业需要绘制出代码中硬编码给出的三个点所构成的三角形,通过将其三维空间坐标变换为屏幕坐标绘制出三角形(代码框架中提供draw函数用于绘制三角形)。
代码框框架仅留下了变换过程中的模型变换投影变换由我们完成。
主要工作如下:

  • 完成模型变换矩阵
  • 完成投影变换矩阵
  • 提高:完成绕任意轴旋转的矩阵
    也就是说本次作业的任务是完成如下模型变换和透视投影变换函数:
get_model_matrix(float rotation_angle);//模型变换矩阵,本次作业中只需要实现三维中绕Z轴旋转的变换矩阵,而不用处理平移与缩放
get_projection_matrix(float eye_fov, float, aspect_ratio, float,zNear, float zFar);//投影变换矩阵

正确完成后,光栅化器会逐帧绘制出三角形,并且使用A/D键可以实现三角形逆/顺时针旋转,按下Esc键窗口关闭,程序终止。

重点提要

本次作业需要对课程中M.V.P变换的理解,重点在于三维旋转和透视投影变换的过程以及它们的矩阵表示,提高部分需要使用罗德里格斯旋转公式。

本次作业基础知识梳理可参看本博客GAMES101知识梳理:变换


完成作业

基础

//模型变换矩阵
Eigen::Matrix4f get_model_matrix(float rotation_angle)
{
    Eigen::Matrix4f model = Eigen::Matrix4f::Identity();

    // TODO: Implement this function
    // Create the model matrix for rotating the triangle around the Z axis.
    // Then return it.

    float rAngleRad = rotation_angle / 180 * MY_PI;
    model <<
        cos(rAngleRad), -sin(rAngleRad), 0, 0,
        sin(rAngleRad), cos(rAngleRad), 0, 0,
        0, 0, 1, 0,
        0, 0, 0, 1;

    return model;
}

以上为模型变换矩阵,没有什么特别之处,套用绕Z轴旋转矩阵即可。
唯一需要注意的是框架中的旋转角度是角度制表示,在进行三角函数计算前需先转为弧度制。

绕Z轴旋转矩阵

Rz(α)=[cosαsinα00sinαcosα0000100001]R_z(\alpha)= \begin{bmatrix} cos\alpha & -sin\alpha & 0 & 0 \\ sin\alpha & cos\alpha & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix}

//透视投影矩阵
Eigen::Matrix4f get_projection_matrix(float eye_fov, float aspect_ratio, float zNear, float zFar)
{
    Eigen::Matrix4f projection = Eigen::Matrix4f::Identity();

    float fov_rad = eye_fov / 180 * MY_PI;
    float n = -zNear, f = -zFar;
    float t = abs(n) * tan(fov_rad / 2);
    float b = -t;
    float r = t * aspect_ratio;
    float l = -r;

    Eigen::Matrix4f persp2ortho = Eigen::Matrix4f::Identity();
    Eigen::Matrix4f ortho1 = Eigen::Matrix4f::Identity();
    Eigen::Matrix4f ortho2 = Eigen::Matrix4f::Identity();
    persp2ortho <<
        n, 0, 0, 0,
        0, n, 0, 0,
        0, 0, n + f, -n * f,
        0, 0, 1, 0;
    ortho1 <<
        1, 0, 0, -(r + l) / 2,
        0, 1, 0, -(t + b) / 2,
        0, 0, 1, -(n + f) / 2,
        0, 0, 0, 1;
    ortho2 <<
        2 / (r - l), 0, 0, 0,
        0, 2 / (t - b), 0, 0,
        0, 0, 2 / (n - f), 0,
        0, 0, 0, 1;

    projection = ortho1 * ortho2 * persp2ortho;

    return projection;
}

以上为透视投影矩阵。这里需要特别注意的是如果直接将zNear和zFar当作n和f,渲染出的三角形会是颠倒的。这是因为代码框架中调用该函数时,输入的zNear和zFar是正值,且zNear < zFar,因此我们应将其当成是视锥体近远裁剪面与相机间的距离,在计算前需要将其先取反转换为坐标,得到n和f,即n=-zNear,f=-zFar。

效果
basic1
basic2
basic3

提高

Eigen::Matrix4f get_rotation(Eigen::Vector3f axis, float angle)
{
    float angle_rad = angle / 180 * MY_PI;
    Eigen::Vector3f n;
    Eigen::Matrix3f I, N, Rodrigues;
    Eigen::Matrix4f rotation = Eigen::Matrix4f::Identity();
    
    n = axis.normalized();//归一化旋转轴
    I <<
        1, 0, 0,
        0, 1, 0,
        0, 0, 1;
    N <<
        0, -axis.z(), axis.y(),
        axis.z(), 0, -axis.x(),
        -axis.y(), axis.x(), 0;
    
    Rodrigues = cos(angle_rad) * I + (1 - cos(angle_rad)) * n * n.transpose() + sin(angle_rad) * N;
    rotation.block<3, 3>(0, 0) = Rodrigues;
    rotation(3, 3) = 1;
    
    return rotation;
}

套用罗德里格斯旋转公式即可,公式如下(公式推导参考:3D Math Primer for Graphics and Game Development - Ch5.1.3)

R(n,α)=cos(α)I+(1cos(α))nnT+sin(α)[0nznynz0nxnynx0]NR(\bm{n},\alpha)=cos(\alpha)\bold{I}+(1-cos(\alpha))\bm{n}\bm{n^T}+sin(\alpha)\underbrace{ \begin{bmatrix} 0 & -n_z & n_y \\ n_z & 0 & -n_x \\ -n_y & n_x & 0 \end{bmatrix} }_{\text{N}}

代码中先将输入的过原点的旋转轴归一化为单位向量n,根据公式写出矩阵。代码中先计算出的Rodrigues矩阵是一个三维矩阵,我们需要使用齐次坐标变换,应该是四维矩阵,所以最后将Rodrigues矩阵填入rotation矩阵的左上角,并将右下角元素填为1。

效果
n=(0,1,0)Tn=(0,1,0)^T旋转
advance1
advance2
advance3
advance4


代码框架说明

下面将代码框架主要分为三角形类和光栅化渲染器类分别说明。

注意事项

  1. 为了说明更加清晰,下面的代码框架分析中选择将类分为数据成员与成员函数进行说明,而不是按照声明类体的顺序。
  2. 代码框架中部分数据成员与成员函数是为后续作业准备,本次作业不需要使用。

Triangle

三角形类,主要作用是对顶点坐标、颜色、法线、纹理等信息的存储。

数据成员

Vector3f v[3];//存储顶点
Vector3f color[3];//存储顶点颜色信息,本次作业不需要
Vector2f tex_coords[3];//存储顶点纹理坐标,本次作业不需要
Vector3f normal[3];//存储顶点法线

成员函数

Eigen::Vector3f a() const { return v[0]; }//返回第一个顶点坐标
Eigen::Vector3f b() const { return v[1]; }//返回第二个顶点坐标
Eigen::Vector3f c() const { return v[2]; }//返回第三个顶点坐标

void setVertex(int ind, Vector3f ver);//将顶点ver存储为v[ind]
void setNormal(int ind, Vector3f n);//将法线n存储为normal[ind]
void setColor(int ind, float r, float g, float b);//设定顶点颜色
void setTexCoord(int ind, float s, float t);//设定顶点纹理坐标
std::array<Vector4f, 3> toVector4() const;//第四维补1,转为齐次坐标

rasterizer

光栅化渲染器类,主要用于实现光栅化渲染的全过程。

Buffers

//区分缓冲区
enum class Buffers
{
    Color = 1,
    Depth = 2
};
//重载位运算符
inline Buffers operator|(Buffers a, Buffers b)
{
    return Buffers((int)a | (int)b);
}
inline Buffers operator&(Buffers a, Buffers b)
{
    return Buffers((int)a & (int)b);
}
//上面的定义用于清除缓冲区时进行选择,选择清楚帧缓冲区还是深度缓冲区还是两者都清除

//区分绘制的图元种类,本次作业代码框架中draw函数仅实现了Triangle图元绘制
enum class Primitive
{
    Line,
    Triangle
};

//如下两个结构体是为了防止调用draw函数时参数列表中输入pos_id和ind_id顺序错误,因为两者类型都是int,输入顺序错误该函数仍然会编译,而导致出现错误,而这种问题往往不便于检查。因此,使用如下结构体将两者“变为不同类型”,这样,当输入顺序错误时函数不会被编译。这就是所谓的“类型安全”。
struct pos_buf_id
{
    int pos_id = 0;
};
struct ind_buf_id
{
    int ind_id = 0;
};

数据成员

Eigen::Matrix4f model;//模型变换矩阵
Eigen::Matrix4f view;//视图变换矩阵
Eigen::Matrix4f projection;//透视投影变换矩阵

std::map<int, std::vector<Eigen::Vector3f>> pos_buf;//存储所有三角形的顶点位置信息,以<三角形索引值,三角形三个顶点坐标数组>的键值对形式
std::map<int, std::vector<Eigen::Vector3i>> ind_buf;//存储各点在对应三角形中的索引信息,以<三角形索引值,该三角形内三个点的索引(0,1,2)>的键值对形式

std::vector<Eigen::Vector3f> frame_buf;//帧缓冲区
std::vector<float> depth_buf;//深度缓冲区

int width, height;//屏幕宽高
int next_id = 0;//下一个索引

成员函数

rasterizer(int w, int h);//构造函数,设定屏幕大小

pos_buf_id load_positions(const std::vector<Eigen::Vector3f>& positions);//读入点的索引和其位置的键值对,并返回点总数的索引数(此处有一个int到struct pos_buf_id的隐式转换)
ind_buf_id load_indices(const std::vector<Eigen::Vector3i>& indices);//读入图元(三角形)的索引和其中各点在图元中索引的键值对,并返回图元总数的索引数(此处有一个int到struct ind_buf_id的隐式转换)

void set_model(const Eigen::Matrix4f& m); //设定模型变换矩阵
void set_view(const Eigen::Matrix4f& v); //设定视图/相机变换矩阵
void set_projection(const Eigen::Matrix4f& p); //设定透视变换矩阵

void set_pixel(const Eigen::Vector3f& point, const Eigen::Vector3f& color);//将需要绘制的点(屏幕范围内的点)的像素信息存入帧缓冲区frame_buf里

int get_index(int x, int y);//获取像素索引

void clear(Buffers buff);//清除屏幕 将帧缓冲区和深度缓存区清零

std::vector<Eigen::Vector3f>& frame_buffer() { return frame_buf; }//返回帧缓冲区

void rasterize_wireframe(const Triangle& t);//内部调用三次draw_line画出三角形

void draw(pos_buf_id pos_buffer, ind_buf_id ind_buffer, Primitive type);//见下方分析

void draw_line(Eigen::Vector3f begin, Eigen::Vector3f end);//使用Bresenham's line drawing algorithm,绘制两点之间的线段。在其中调用了set_pixel完成对帧缓冲区frame_buf的构建。
draw

draw函数中实现了视口变换,并绘制出三角形

void rst::rasterizer::draw(rst::pos_buf_id pos_buffer, rst::ind_buf_id ind_buffer, rst::Primitive type)
{
    if (type != rst::Primitive::Triangle)
    {
        throw std::runtime_error("Drawing primitives other than triangle is not implemented yet!");
    }
    auto& buf = pos_buf[pos_buffer.pos_id];//buf存储当前索引对应点的位置信息
    auto& ind = ind_buf[ind_buffer.ind_id];//ind存储当前索引对应图元(三角形)中各点的索引信息

    float f1 = (100 - 0.1) / 2.0;
    float f2 = (100 + 0.1) / 2.0;

    Eigen::Matrix4f mvp = projection * view * model;
    for (auto& i : ind) //范围for语句遍历每个图元(三角形)
    {
        Triangle t;//创建三角形类对象t

        //对于每个图元(三角形),从buf中读入其各点的空间位置坐标。补1转成齐次坐标,再进行M.V.P变换,将变换后各点的坐标存入v,此处存入的点就是其在[-1,1]^3空间内的坐标
        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)
        };

        //除以第四维w,变成(x,y,z,1)形式,上面补的w是1,所以这里不做也一样
        for (auto& vec : v) {
            vec /= vec.w();
        }

        //视口变换
        for (auto & vert : v)
        {
            vert.x() = 0.5*width*(vert.x()+1.0);//X轴范围从[-1,1]变换为[0,width]
            vert.y() = 0.5*height*(vert.y()+1.0);//Y轴范围从[-1,1]变换为[0,height]
            vert.z() = vert.z() * f1 + f2;
        }

        //到这一步,三角形顶点的X,Y坐标已经对应了屏幕的坐标,后面便是对三角形对象t进行构建

        //v前三维坐标对应三角形顶点的X,Y,Z坐标
        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);
    }
}

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

    rst::rasterizer r(700, 700);

    Eigen::Vector3f eye_pos = {0, 0, 5};

    std::vector<Eigen::Vector3f> pos{{2, 0, -2}, {0, 2, -2}, {-2, 0, -2}};

    std::vector<Eigen::Vector3i> ind{{0, 1, 2}};

    auto pos_id = r.load_positions(pos);//读入上述点的位置信息,并返回三角形的索引数
    auto ind_id = r.load_indices(ind);//读入上述索引信息,并返回三角形的索引数

    int key = 0;
    int frame_count = 0;

    //未使用命令行,命令行代码略去

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

        //提高部分使用
        // Eigen::Vector3f axis(0, 1, 0);
        // r.set_model(get_rotation(axis, angle));

        //获取mvp变换矩阵
        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);

        //创建图片并显示
        cv::Mat image(700, 700, CV_32FC3, r.frame_buffer().data());
        image.convertTo(image, CV_8UC3, 1.0f);
        cv::imshow("image", image);
        key = cv::waitKey(10);

        std::cout << "frame count: " << frame_count++ << '\n';

        if (key == 'a') {
            angle += 10;
        }
        else if (key == 'd') {
            angle -= 10;
        }
    }
    return 0;
}

程序中buffer的说明

代码中各个buffer是程序运行过程中数据存储、计算、转移最重要的载体。在理解代码框架的过程中,各个buffer在程序运行过程中的变化是困扰我最多的。

为了加深理解,这里对各个buffer的进行简要分析。

代码框架中的buffer如下

// rasterizer.hpp
std::map<int, std::vector<Eigen::Vector3f>> pos_buf;
std::map<int, std::vector<Eigen::Vector3i>> ind_buf;
std::vector<Eigen::Vector3f> frame_buf;
std::vector<float> depth_buf;//本次作业不需要

其中pos_buf和ind_buf都有一个结构体封装这其容器元素的索引,此处封装的目的仅仅是为了防止调用draw函数时因为两者数据类型相同而搞错顺序,如下

// rasterizer.hpp
struct pos_buf_id
{
    int pos_id = 0;
};
struct ind_buf_id
{
    int ind_id = 0;
};

下面以main中的输入为例进行分析

// main.cpp
std::vector<Eigen::Vector3f> pos{{2, 0, -2}, {0, 2, -2}, {-2, 0, -2}};
std::vector<Eigen::Vector3i> ind{{0, 1, 2}};

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

首先,在main函数中,使用load_positions和load_indices函数将三角形顶点和其内部索引值分别读入pos_buf和ind_buf。

需要说明的是,pos_buf和ind_buf将索引和元素作为键值对存储。
其中pos_buf每个键值对的值是单个三角形三个顶点的坐标,是一个内含三个Vector3f向量的vector。
而ind_buf每个键值对的值是单个三角形三个顶点在该三角形内部的索引,是一个内含一个Vector3i向量的vector。

此时,两个buffer中存储情况如下

pos_buf: { < 0, {{2, 0, -2}, {0, 2, -2}, {-2, 0, -2}} > }
ind_buf: { < 0, {{0, 1, 2}} > }

两者中都只有如上所示的一个键值对。此时其对应结构体中的索引值都是0。

两者的数据在draw函数中使用

// rasterizer.cpp  draw()
auto& buf = pos_buf[pos_buffer.pos_id];
auto& ind = ind_buf[ind_buffer.ind_id];

在draw函数中,buf取出pos_buf[0],ind取出ind_buf[0]。
两者的存储情况为

buf: {{2, 0, -2}, {0, 2, -2}, {-2, 0, -2}}
ind: {{0, 1, 2}}

然后在draw函数中的范围for语句里,使用ind中的三个内部索引值分别取出buf对应的三个Vector3f(即ind中0对应取出buf中{2,0,-2},以此类推),转换成齐次坐标后进行mvp变换和视口变换。使用变换完成的坐标构建三角形类对象,并设定颜色。

在draw_line函数中调用set_pixel函数,根据绘制的情况,将像素信息存入frame_buf。frame_buf中按行列顺序依次存储各个像素的颜色。
最后使用opencv根据frame_buf存储像素的信息生成一帧图像。