前言

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

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


作业描述

任务说明

本次作业主要是在前两次作业的基础上更进一步,完成整个模型的光栅化与着色。
主要工作如下:

  • 完成函数rasterize_triangle(const Triangle& t) in rasterizer.cpp,在作业2深度测试的基础上,加入法向量、颜色、纹理颜色等插值
  • 完成函数get_projection_matrix() in main.cpp,复制前两次作业中的即可
  • 完成函数phong_fragment_shader() in main.cpp,实现Blinn-Phong模型计算Fragment Color
  • 完成函数texture_fragment_shader() in main.cpp,在Blinn-Phong模型基础上,将纹理颜色(而不是顶点插值颜色)作为kdk_d
  • 完成函数bump_fragment_shader() in main.cpp,在Blinn-Phong模型基础上,实现凹凸贴图
  • 完成函数displacement_fragment_shader() in main.cpp,在凹凸贴图基础上实现位移贴图
  • 提高:使用双线性插值进行纹理采样,(本次作业我未做换其他模型的提高内容,懒…)

重点提要

Blinn-Phong模型、插值、Bump/Normal Mapping、Displacement mapping…

相关基础知识梳理可参看本博客GAMES101知识梳理:着色


完成作业

get_projection_matrix()直接复制前两次的即可。注意Z值正负问题。

:代码框架部分使用了一些C++17的新特性,如std::optional。完成作业时候记得将编译器版本改为C++17,或自行用更早C++版本重新实现那些内容。

rasterize_triangle函数

// in rasterizer.cpp
void rst::rasterizer::rasterize_triangle(const Triangle& t, const std::array<Eigen::Vector3f, 3>& view_pos) 
{
    auto v = t.toVector4();
    Vector3f color;
    // 包围盒计算
    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);
                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;

                // 深度值小于当前存储的深度值
                int ind = get_index(x, y);
                if (z_interpolated < depth_buf[ind])
                {
                    depth_buf[ind] = z_interpolated;//更新深度值
                    
                    auto interpolated_color = interpolate(alpha, beta, gamma, t.color[0], t.color[1], t.color[2], 1);//颜色插值
                    auto interpolated_normal = interpolate(alpha, beta, gamma, t.normal[0], t.normal[1], t.normal[2], 1).normalized();//法向量插值
                    auto interpolated_texcoords = interpolate(alpha, beta, gamma, t.tex_coords[0], t.tex_coords[1], t.tex_coords[2], 1);//纹理坐标插值
                    auto interpolated_shadingcoords = interpolate(alpha, beta, gamma, view_pos[0], view_pos[1], view_pos[2], 1);//着色点坐标插值

                    fragment_shader_payload payload(interpolated_color, interpolated_normal, interpolated_texcoords, &texture.value());// 使用插值结果初始化需要传给Shaders的payload
                    payload.view_pos = interpolated_shadingcoords;
                    
                    color = fragment_shader(payload);//使用Shader计算当前像素着色结果
                    set_pixel({ x,y }, color);//设置当前像素的颜色
                }
            }
        }
    }
}

rasterize_triangle函数中的包围盒计算以及深度测试与作业2完全相同,直接复制过来即可(这里我并未使用MSAA)。修改的部分就是像素颜色的计算,作业2是直接使用了顶点颜色常数值,但作业3按照Barycentric Coordinates对法向量、颜色、纹理坐标等进行插值。计算出的[alpha, beta, gamma]就是顶点权重,像深度插值一样将其应用在其他参数的插值上即可。使用插值计算出的结果设置fragment shader payload,传入Fragment Shader计算得到颜色值,将该颜色写入framebuffer。

:法向量记得要normalized()

Fragment_shaders

在main.cpp中完成各fragment_shader的定义。
这部分实际上就是在定义不同fragment_shader如何处理输入的Payload,从而计算得到不同的像素颜色值。

代码框架中给出了normal_fragment_shader,这个fragment_shader实际上是将每个像素的法向量的坐标计算成颜色值。
完成上述rasterize_triangle函数后可以用其测试。

效果
normal

phong_fragment_shader

// in main.cpp
Eigen::Vector3f phong_fragment_shader(const fragment_shader_payload& payload)
{
    Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005);
    Eigen::Vector3f kd = payload.color;
    Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937);

    auto l1 = light{{20, 20, 20}, {500, 500, 500}};
    auto l2 = light{{-20, 20, 0}, {500, 500, 500}};

    std::vector<light> lights = {l1, l2};
    Eigen::Vector3f amb_light_intensity{10, 10, 10};
    Eigen::Vector3f eye_pos{0, 0, 10};

    float p = 150;

    Eigen::Vector3f color = payload.color;
    Eigen::Vector3f point = payload.view_pos;
    Eigen::Vector3f normal = payload.normal;

    Eigen::Vector3f l, v, h, ambient, diffuse, specular;
    float r2;
    Eigen::Vector3f result_color = { 0, 0, 0 };
    for (auto& light : lights)//对每个点光源执行
    {
        // TODO: For each light source in the code, calculate what the *ambient*, *diffuse*, and *specular* 
        // components are. Then, accumulate that result on the *result_color* object.
        l = (light.position - point).normalized();//着色点->点光源
        v = (eye_pos - point).normalized();//着色点->眼睛
        h = (v + l).normalized();//半程向量
        r2 = (light.position - point).dot(light.position - point);//距离平方

        ambient = ka.cwiseProduct(amb_light_intensity);//环境光项
        diffuse = kd.cwiseProduct(light.intensity / r2) * std::max(0.0f, normal.dot(l));//漫反射项
        specular = ks.cwiseProduct(light.intensity / r2) * pow(std::max(0.0f, normal.dot(h)), p);//镜面反射项

        result_color += (ambient + diffuse + specular);
    }

    return result_color * 255.f;
}

主要是blinn-phong模型的实现,大体上按照blinn-phong模型三种光的公式写代码即可,注意向量的归一化,否则可能会出现错误的效果。

这里还有一种写法——每个点光源先加diffuse漫反射项和specular镜面反射项,将ambient环境光项放在对所有点光源计算完毕后再一次性加上。
我认为也是可以的,但是这样写环境光项应该要乘以点光源个数,而不是像上面代码那样。环境光主要是其他物体反射的光,但是这些光归根到底来自光源,因此光源数量越多环境光项显然应该越大,所以环境光项应该对每个点光源都加,如果最终一次性加上也应该考虑所有光源。

效果
phong

texture_fragment_shader

// in main.cpp
Eigen::Vector3f texture_fragment_shader(const fragment_shader_payload& payload)
{
    Eigen::Vector3f return_color = {0, 0, 0};
    if (payload.texture)
    {
        // TODO: Get the texture value at the texture coordinates of the current fragment
        return_color = payload.texture->getColor(payload.tex_coords.x(), payload.tex_coords.y());
    }
    Eigen::Vector3f texture_color;
    texture_color << return_color.x(), return_color.y(), return_color.z();

    Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005);
    Eigen::Vector3f kd = texture_color / 255.f;
    Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937);

    auto l1 = light{{20, 20, 20}, {500, 500, 500}};
    auto l2 = light{{-20, 20, 0}, {500, 500, 500}};

    std::vector<light> lights = {l1, l2};
    Eigen::Vector3f amb_light_intensity{10, 10, 10};
    Eigen::Vector3f eye_pos{0, 0, 10};

    float p = 150;

    Eigen::Vector3f color = texture_color;
    Eigen::Vector3f point = payload.view_pos;
    Eigen::Vector3f normal = payload.normal;

    Eigen::Vector3f l, v, h, ambient, diffuse, specular;
    float r2;
    Eigen::Vector3f result_color = { 0, 0, 0 };

    for (auto& light : lights)
    {
        // TODO: For each light source in the code, calculate what the *ambient*, *diffuse*, and *specular* 
        // components are. Then, accumulate that result on the *result_color* object.
        l = (light.position - point).normalized();//着色点->点光源
        v = (eye_pos - point).normalized();//着色点->眼睛
        h = (v + l).normalized();//半程向量
        r2 = (light.position - point).dot(light.position - point);//距离平方

        ambient = ka.cwiseProduct(amb_light_intensity);//环境光项
        diffuse = kd.cwiseProduct(light.intensity / r2) * std::max(0.0f, normal.dot(l));//漫反射项
        specular = ks.cwiseProduct(light.intensity / r2) * pow(std::max(0.0f, normal.dot(h)), p);//镜面反射项

        result_color += (ambient + diffuse + specular);
    }

    return result_color * 255.f;
}

与blinn-phong模型实现方法一致,只不过将纹理颜色作为kdk_d

效果
texture

bump_fragment_shader

// in main.cpp
Eigen::Vector3f bump_fragment_shader(const fragment_shader_payload& payload)
{
    Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005);
    Eigen::Vector3f kd = payload.color;
    Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937);

    auto l1 = light{{20, 20, 20}, {500, 500, 500}};
    auto l2 = light{{-20, 20, 0}, {500, 500, 500}};

    std::vector<light> lights = {l1, l2};
    Eigen::Vector3f amb_light_intensity{10, 10, 10};
    Eigen::Vector3f eye_pos{0, 0, 10};

    float p = 150;

    Eigen::Vector3f color = payload.color; 
    Eigen::Vector3f point = payload.view_pos;
    Eigen::Vector3f normal = payload.normal;

    float kh = 0.2, kn = 0.1;

    // TODO: Implement bump mapping here
    // Let n = normal = (x, y, z)
    // Vector t = (x*y/sqrt(x*x+z*z),sqrt(x*x+z*z),z*y/sqrt(x*x+z*z))
    // Vector b = n cross product t
    // Matrix TBN = [t b n]
    // dU = kh * kn * (h(u+1/w,v)-h(u,v))
    // dV = kh * kn * (h(u,v+1/h)-h(u,v))
    // Vector ln = (-dU, -dV, 1)
    // Normal n = normalize(TBN * ln)

    float x = normal.x(), y = normal.y(), z = normal.z();
    Eigen::Vector3f t, b;
    t << x * y / std::sqrt(x * x + z * z), std::sqrt(x * x + z * z), z * y / std::sqrt(x * x + z * z);
    b = normal.cross(t);

    Eigen::Matrix3f TBN;
    TBN << t.x(), b.x(), normal.x(),
        t.y(), b.y(), normal.y(),
        t.z(), b.z(), normal.z();

    float u = payload.tex_coords.x(), v = payload.tex_coords.y();
    float w = payload.texture->width, h = payload.texture->height;

    float dU = kh * kn * (payload.texture->getColor(u + 1.0 / w, v).norm() - payload.texture->getColor(u, v).norm());
    float dV = kh * kn * (payload.texture->getColor(u, v + 1.0 / h).norm() - payload.texture->getColor(u, v).norm());
    
    Eigen::Vector3f ln;
    ln << -dU, -dV, 1;

    normal = (TBN * ln).normalized();

    Eigen::Vector3f result_color = { 0, 0, 0 };
    result_color = normal;

    return result_color * 255.f;
}

代码框架中给出了伪代码,根据伪代码一步一步实现基本上就行了。其原理就是用纹理颜色记录纹理像素高度的变化,具体细节参考GAMES101知识梳理:着色

其中最重要的部分是进行坐标变换。如课程中所述,我们用差分方法进行新的法向量计算时将原法向量设为(0,0,1),也就是说我们使用的是每个像素局部的切线空间坐标系,局部计算结果必须变换回世界坐标系。
切线空间坐标系由T轴(切线tangant)、B轴(副切线bitangent)、N轴(法线normal)构成。
将世界坐标系旋转为切线空间坐标系通过左乘TBN矩阵实现,因此将切线空间中的法向量变换为用世界坐标表示也是左乘TBN矩阵。这一点与课程中讲Viewing Transformation的矩阵推导有点类似,只不过当时是先求相机的逆变换再取逆矩阵,而TBN的推导可以利用旋转坐标系和以相反方式旋转坐标系中的向量效果相同的原理。

TBN=[TxBxNxTyByNyTzBzNz]TBN = \begin{bmatrix} T_x & B_x & N_x \\ T_y & B_y & N_y \\ T_z & B_z & N_z \end{bmatrix}

这里还有个小坑需要注意,就是(u,v)越界问题。u和v应该都在0到1之间,超出范围会导致getColor函数中数组访问越界。我采用的做法是在getColor函数中加入防止越界的代码,小于0则取0,大于1则取1,如下

// in Texture.hpp
Eigen::Vector3f getColor(float u, float v)
{
    if (u < 0)u = 0;
    if (u > 1)u = 1;
    if (v < 0)v = 0;
    if (v > 1)v = 1;

    auto u_img = u * width;
    auto v_img = (1 - v) * height;
    auto color = image_data.at<cv::Vec3b>(v_img, u_img);
    return Eigen::Vector3f(color[0], color[1], color[2]);
}

效果
bump

displacement_fragment_shader

// in main.cpp
Eigen::Vector3f displacement_fragment_shader(const fragment_shader_payload& payload)
{
    
    Eigen::Vector3f ka = Eigen::Vector3f(0.005, 0.005, 0.005);
    Eigen::Vector3f kd = payload.color;
    Eigen::Vector3f ks = Eigen::Vector3f(0.7937, 0.7937, 0.7937);

    auto l1 = light{{20, 20, 20}, {500, 500, 500}};
    auto l2 = light{{-20, 20, 0}, {500, 500, 500}};

    std::vector<light> lights = {l1, l2};
    Eigen::Vector3f amb_light_intensity{10, 10, 10};
    Eigen::Vector3f eye_pos{0, 0, 10};

    float p = 150;

    Eigen::Vector3f color = payload.color; 
    Eigen::Vector3f point = payload.view_pos;
    Eigen::Vector3f normal = payload.normal;

    float kh = 0.2, kn = 0.1;
    
    // TODO: Implement displacement mapping here
    // Let n = normal = (x, y, z)
    // Vector t = (x*y/sqrt(x*x+z*z),sqrt(x*x+z*z),z*y/sqrt(x*x+z*z))
    // Vector b = n cross product t
    // Matrix TBN = [t b n]
    // dU = kh * kn * (h(u+1/w,v)-h(u,v))
    // dV = kh * kn * (h(u,v+1/h)-h(u,v))
    // Vector ln = (-dU, -dV, 1)
    // Position p = p + kn * n * h(u,v)
    // Normal n = normalize(TBN * ln)

    float x = normal.x(), y = normal.y(), z = normal.z();
    Eigen::Vector3f t, b;
    t << x * y / std::sqrt(x * x + z * z), std::sqrt(x * x + z * z), z * y / std::sqrt(x * x + z * z);
    b = normal.cross(t);

    Eigen::Matrix3f TBN;
    TBN << t.x(), b.x(), normal.x(),
        t.y(), b.y(), normal.y(),
        t.z(), b.z(), normal.z();

    float u = payload.tex_coords.x(), v = payload.tex_coords.y();
    float w = payload.texture->width, h = payload.texture->height;

    float dU = kh * kn * (payload.texture->getColor(u + 1.0 / w, v).norm() - payload.texture->getColor(u, v).norm());
    float dV = kh * kn * (payload.texture->getColor(u, v + 1.0 / h).norm() - payload.texture->getColor(u, v).norm());
    
    Eigen::Vector3f ln;
    ln << -dU, -dV, 1;

    point += kn * normal * payload.texture->getColor(u, v).norm();//位移,关键!
    normal = (TBN * ln).normalized();

    Eigen::Vector3f result_color = {0, 0, 0};

    Eigen::Vector3f l, vVec, hVec, ambient, diffuse, specular;
    float r2;
    for (auto& light : lights)
    {
        // TODO: For each light source in the code, calculate what the *ambient*, *diffuse*, and *specular* 
        // components are. Then, accumulate that result on the *result_color* object.

        l = (light.position - point).normalized();
        vVec = (eye_pos - point).normalized();
        hVec = (vVec + l).normalized();
        r2 = (light.position - point).dot(light.position - point);

        ambient = ka.cwiseProduct(amb_light_intensity);
        diffuse = kd.cwiseProduct(light.intensity / r2) * std::max(0.0f, normal.dot(l));
        specular = ks.cwiseProduct(light.intensity / r2) * pow(std::max(0.0f, normal.dot(hVec)), p);

        result_color += (ambient + diffuse + specular);
    }

    return result_color * 255.f;
}

位移贴图相比凹凸贴图,最主要的区别就是其真的将着色点进行位移,代码中point的计算就是位移的关键。
其余部分基本上就是前面blinn-phong模型和bump贴图的结合。

效果
displacement

双线性插值

// in Texture.hpp
Eigen::Vector3f getColorBilinearInterpolation(float u, float v)
{
    if (u < 0)u = 0;
    if (u > 1)u = 1;
    if (v < 0)v = 0;
    if (v > 1)v = 1;

    float w00 = std::floor(u * width), h00 = std::floor((1-v) * height);
    float w10 = w00 + 1.0f, h10 = h00;
    float w01 = w00, h01 = h00 + 1.0f;
    float w11 = w00 + 1.0f, h11 = h00 + 1.0f;

    auto color00 = image_data.at<cv::Vec3b>(w00, h00);
    auto color10 = image_data.at<cv::Vec3b>(w10, h10);
    auto color01 = image_data.at<cv::Vec3b>(w01, h01);
    auto color11 = image_data.at<cv::Vec3b>(w11, h11);
    auto color0 = color00 + (color10 - color00) * (u * width - w00);
    auto color1 = color01 + (color11 - color01) * (u * width - w01);//w01==w00
    auto color = color0 + (color1 - color0) * ((1 - v) * height - h00);

    return Eigen::Vector3f(color[0], color[1], color[2]);
}

在Texture类中新增一个用双线性插值进行纹理采样的成员函数,具体做法与课程中所述双线性插值完全一致,使用相邻的四个纹素进行插值。

在bump_fragment_shader中替换原纹理采样函数getColor,结果对比如下
Bilinear
可见双线性插值采样的纹理要更加平滑、连续一些。


代码框架说明

本次的框架是在作业1、作业2的基础上进一步扩充的,只要先前作业中明白框架大致做了些什么,大体理解本次作业框架应该没有太大问题。
作业的说明文档中对框架变化讲解已经较为详细,其实看文档已经完全足矣。这里为了便于后续快速复习做简要总结。

关于代码框架整体更加详细的说明可以参看GAMES101作业1:旋转与投影。理解作业1代码框架的内容后,只需稍微补充理解后两次作业新增内容就可以快速上手作业框架。

新增Texture类

从图片中读入纹理。成员函数Vector3f getColor(float u, float v)用于查找纹理、纹理采样,输入(u,v)坐标,返回颜色值(三维)。
提高部分的双线性插值实际上就是新增一个查找纹理的成员函数代替getColor。

rasterizer

  • 增加插值函数interpolate in rasterizer.cpp
    不是成员函数,是新定义的普通函数。对三角形顶点的属性(颜色、法向量、纹理坐标等)进行插值。含重载版本,分别对Vector3f和Vector2f类型插值。

  • rasterize_triangle()变化

    1. 变为rasterize_triangle(const Triangle& t, const std::array<Eigen::Vector3f, 3>& view_pos)先前仅传入一个三角形对象,本次需要多传入一个经过模型视图变换的三角形顶点的数组。因为如课程中所说,重心坐标没有投影不变性,我们计算着色点坐标必须使用投影变换之前的顶点。
    2. set_pixel()写入frame_buf的像素颜色改成使用着色器算出来的颜色。

Shader.hpp

定义了fragment_shader_payload,其中包括了Fragment Shader可能用到的参数。
main.cpp中有三个Fragment Shader,其中normal_fragment_shader是按照法向量上色的样例Shader,其余Shader为作业实现内容。

OBJ_Loader.h

第三方.obj文件加载库,用于读取更加复杂的模型文件。无需详细理解其工作原理,只需知道这个库将会传递给我们一个被命名被TriangleList的Vector,其中每个三角形都有对应的点法向量与纹理坐标。此外,与模型相关的纹理也将被一同加载。