前言

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

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


作业描述

任务说明

本次作业在作业1基础上更进一步,需要画出两个有遮挡关系的实心三角形,实现真正的光栅化过程。
主要工作如下:

  • 完成static bool insideTriangle()函数,用于判断像素是否在三角形覆盖范围内
  • 完成rasterize_triangle(const Triangle& t)函数
    • 构建三角形的包围盒
    • 实现Z-bufferring
    • 提高:实现4×\timesMSAA

重点提要

本次作业重点是对于采样过程以及Z-buffer的理解。
相关基础知识梳理可参看本博客GAMES101知识梳理:光栅化


完成作业

基础

代码框架中main.cpp未补完的get_projection_matrix()是作业1的内容,直接将作业1中的实现复制过来即可。

// rasterizer.cpp
static bool insideTriangle(float x, float y, const Vector3f* _v)
{   
    Vector3f v01, v12, v20, v0p, v1p, v2p, cross0, cross1, cross2;
    // 三边向量
    v01 = _v[1] - _v[0];
    v12 = _v[2] - _v[1];
    v20 = _v[0] - _v[2];
    // 顶点到点(x,y)的向量
    v0p = Vector3f(x - _v[0].x(), y - _v[0].y(), 0);
    v1p = Vector3f(x - _v[1].x(), y - _v[1].y(), 0);
    v2p = Vector3f(x - _v[2].x(), y - _v[2].y(), 0);
    // 叉乘结果
    cross0 = v01.cross(v0p);
    cross1 = v12.cross(v1p);
    cross2 = v20.cross(v2p);
    // 叉乘同号在本作业背景下就是叉乘结果的向量同向,因此比较叉乘结果与三角形垂直的分量z即可
    if ((cross0.z() > 0 && cross1.z() > 0 && cross2.z() > 0)
        || (cross0.z() < 0 && cross1.z() < 0 && cross2.z() < 0))
    {
        return true;
    }
    return false;
}

以上是insideTriangle函数的实现,基本原理就是课程中所述看三个叉乘是否同号,同号则点在三角形内。

// rasterizer.cpp
void rst::rasterizer::rasterize_triangle(const Triangle& t) {
    auto v = t.toVector4();

    // 确定包围盒
    Vector2i pmin, pmax;
    pmin.x() = std::min(v[0].x(), std::min(v[1].x(), v[2].x()));
    pmin.y() = std::min(v[0].y(), std::min(v[1].y(), v[2].y()));
    pmax.x() = std::max(v[0].x(), std::max(v[1].x(), v[2].x()));
    pmax.y() = std::max(v[0].y(), std::max(v[1].y(), v[2].y()));

    // 遍历包围盒中像素点
    for (int y = pmin.y(); y <= pmax.y(); ++y)
    {
        for (int x = pmin.x(); x <= pmax.x(); ++x)
        {
            // 像素中心是否在三角形中
            if (insideTriangle(x + 0.5f, y + 0.5f, t.v))
            {
                // 深度值插值
                auto [alpha, beta, gamma] = computeBarycentric2D(x + 0.5f, y + 0.5f, t.v);// 计算像素中心的重心坐标表示,alpha, beta, gamma为由三顶点表示该像素中心的权重
                float w_reciprocal = 1.0/(alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w());
                float z_interpolated = -(alpha * v[0].z() / v[0].w() + beta * v[1].z() / v[1].w() + gamma * v[2].z() / v[2].w());
                z_interpolated *= w_reciprocal;
                // 当前深度值是否比已记录的深度值小,如果更小则更新最小深度和像素颜色信息
                if (z_interpolated < depth_buf[get_index(x, y)])
                {
                    depth_buf[get_index(x, y)] = z_interpolated;
                    Vector3f color = t.getColor();
                    set_pixel({ x,y,1 }, color);
                }
            }
        }
    }
}

以上是rasterize_triangle函数的实现。
首先要完成包围盒的构建,取三角形三个顶点中的最大和最小的x,y值构成最小点和最大点,以这两点为对角构成矩形包围盒。一般来说,要将三角形完全包围在内理应最小点下取整,最大点上取整,但是考虑到我们是用像素中心进行判断,计算时坐标值要加0.5,因此实际上就相当于上取整了,所以pmax坐标计算时没有+1;
深度值插值的代码使用代码框架给出的即可,这里将z_interpolated取负,因为顶点v取得的z值为负的坐标值,而我们要保存的深度值是正值。

  • 代码框架给出的auto[alpha, beta, gamma]这种写法是C++17的新特性,使用这种写法需要设置一下编译器版本。
  • 在完成作业过程中,看到有各路大神讨论此处深度插值中透视矫正的问题,我个人对此无深入了解,便不在此妄加议论,完成作业时也未更改原框架,据大神们所说透视矫正问题会带来一些偏差,但是在此作业中基本上看不出来,作业3也类似。

效果
1
如果放大图片看,锯齿还是非常明显的。

提高

作业中实现MSAA我们最先想到的可能就是将像素拆分为2$\times2的采样,对四个采样的像素中心逐一判断是否在三角形内,将三角形颜色乘以在内部的采样数目比例作为该像素的颜色值。这样做确实会使边缘模糊,实现MSAA,但是由于这样的做法其实是将三角形颜色与背景色黑色按比例混合。这种做法的坏处就是在三角形遮挡的边缘会出现黑线。如图所示![黑线](./GAMES101为解决这个问题,我们需要为拆分出的采样点专门构建一个帧缓冲区和一个深度缓冲区来分别存储其各自的像素颜色与深度信息,因为是22的采样,对四个采样的像素中心逐一判断是否在三角形内,将三角形颜色乘以在内部的采样数目比例作为该像素的颜色值。 这样做确实会使边缘模糊,实现MSAA,但是由于这样的做法其实是将三角形颜色与背景色黑色按比例混合。这种做法的坏处就是在三角形遮挡的边缘会出现黑线。如图所示 ![黑线](./GAMES101%E4%BD%9C%E4%B8%9A2%EF%BC%9ATriangles-and-Z-buffering/2.png) 为解决这个问题,我们需要为拆分出的采样点专门构建一个帧缓冲区和一个深度缓冲区来分别存储其各自的像素颜色与深度信息,因为是2\times$2MSAA,这两个缓冲区的大小是代码框架中原先为屏幕像素构建的缓冲区大小的4倍。

// rasterizer.h
std::vector<Eigen::Vector3f> sample_frame_buf;
std::vector<float> sample_depth_buf;

记得修改初始化函数与清空函数,在里面也加上新的缓冲区,如下

// rasterizer.cpp
void rst::rasterizer::clear(rst::Buffers buff)
{
    if ((buff & rst::Buffers::Color) == rst::Buffers::Color)
    {
        std::fill(frame_buf.begin(), frame_buf.end(), Eigen::Vector3f{ 0, 0, 0 });
        std::fill(sample_frame_buf.begin(), sample_frame_buf.end(), Eigen::Vector3f{0, 0, 0});
        
    }
    if ((buff & rst::Buffers::Depth) == rst::Buffers::Depth)
    {
        std::fill(depth_buf.begin(), depth_buf.end(), std::numeric_limits<float>::infinity());
        std::fill(sample_depth_buf.begin(), sample_depth_buf.end(), std::numeric_limits<float>::infinity());
    }
}

void rst::rasterizer::clear(rst::Buffers buff)
{
    if ((buff & rst::Buffers::Color) == rst::Buffers::Color)
    {
        std::fill(frame_buf.begin(), frame_buf.end(), Eigen::Vector3f{ 0, 0, 0 });
        std::fill(sample_frame_buf.begin(), sample_frame_buf.end(), Eigen::Vector3f{0, 0, 0});
        
    }
    if ((buff & rst::Buffers::Depth) == rst::Buffers::Depth)
    {
        std::fill(depth_buf.begin(), depth_buf.end(), std::numeric_limits<float>::infinity());
        std::fill(sample_depth_buf.begin(), sample_depth_buf.end(), std::numeric_limits<float>::infinity());
    }
}

对于每个采样点,分别进行深度测试与颜色设置,最后使用四个采样点中的最小深度作为当前像素的深度,使用四个采样点的颜色平均值作为像素颜色。这样一来,对于两个三角形遮挡的边缘,在三角形外的采样点颜色使用的是后面三角形的颜色,在颜色平均时是使用两个三角形颜色进行平均,而不是和背景色黑色平均,所以就不会出现黑线。

// rasterizer.cpp
void rst::rasterizer::rasterize_triangle(const Triangle& t) {
    auto v = t.toVector4();
    // 通过这种索引形式依次可以取得(x+0.25,y+0.25),(x+0.25,y+0.75),(x+0.75,y+0.75),(x+0.75,y+0.25)
    std::vector<float> delta{ 0.25,0.25,0.75,0.75,0.25 };
    // 确定包围盒
    Vector2i pmin, pmax;
    pmin.x() = std::min(v[0].x(), std::min(v[1].x(), v[2].x()));
    pmin.y() = std::min(v[0].y(), std::min(v[1].y(), v[2].y()));
    pmax.x() = std::max(v[0].x(), std::max(v[1].x(), v[2].x()));
    pmax.y() = std::max(v[0].y(), std::max(v[1].y(), v[2].y()));
    // 遍历包围盒内像素
    for (int y = pmin.y(); y <= pmax.y(); ++y)
    {
        for (int x = pmin.x(); x <= pmax.x(); ++x)
        {
            int ind = get_index(x, y) * 4;
            // 四个采样点在采样点的缓冲区中的索引依次为,ind,ind+1,ind+2,ind+3
            for (int i = 0; i < 4; ++i)
            {
                if (insideTriangle(x + delta[i], y + delta[i + 1], t.v))
                {
                    // 对采样点进行深度值插值
                    auto [alpha, beta, gamma] = computeBarycentric2D(x + delta[i], y + delta[i+1], t.v);
                    float w_reciprocal = 1.0/(alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w());
                    float z_interpolated = -(alpha * v[0].z() / v[0].w() + beta * v[1].z() / v[1].w() + gamma * v[2].z() / v[2].w());
                    z_interpolated *= w_reciprocal;
                    // 采样点深度值小于当前深度缓冲区存储的值,则更新深度值,设置采样点颜色
                    if (z_interpolated < sample_depth_buf[ind + i])
                    {
                        sample_depth_buf[ind + i] = z_interpolated;
                        sample_frame_buf[ind + i] = t.getColor();
                    }
                    // 取四个采样点中最小深度值作为当前像素的深度值
                    depth_buf[get_index(x, y)] = depth_buf[get_index(x, y)] > sample_depth_buf[ind + i] ? sample_depth_buf[ind + i] : depth_buf[get_index(x, y)];
                }
            }
            // 计算四个采样点颜色平均值作为像素颜色
            Vector3f color = (sample_frame_buf[ind] + sample_frame_buf[ind + 1] + sample_frame_buf[ind + 2] + sample_frame_buf[ind + 3]) / 4;
            // 设置像素颜色  frame_buf
            set_pixel({ x,y,1 }, color);
        }
    }
}

效果
MSAA


代码框架说明

作业2的代码框架就是在作业1的基础上修改的,整体变化不大。因此本次作业就对代码框架主要的变化之处进行简要说明,更具体的说明可以参考GAMES101作业1:旋转与投影

Triangle

  1. 删除了a(),b(),c()三个获取顶点的成员函数;
  2. 新增gerColor成员函数,用于获取三角形颜色,由于本次作业中每个三角形都只有一种颜色,因此gerColor函数只需获取一个顶点的颜色即可。

rasterizer

  1. 新增col_buf成员和load_colors成员函数,分别用于存储三角形顶点颜色和将给定的三角形顶点颜色读入col_buf
  2. 在rasterizer.cpp中增加了computeBarycentric2D函数(非成员函数)的定义,用于计算某点的三角形重心坐标表示;
    • 注:这里有个问题:如果使用重心坐标表示,似乎可以直接用其结果判断该点是否在三角形内而不再需要inside函数
  3. 增加我们实现的insideTriangle函数;
  4. 删除rasterizer_wireframe函数,取而代之的是我们在作业中实现的rasterizer_triangle函数;
  5. 原先的draw函数中增加对col_buf的读取,并设置三角形颜色,最后调用我们的rasterizer_triangle函数进行绘制。