Pipeline and Shading

在上一个 Assignment 中,我们完成了一个基础的软件光栅化工具。写到最后,屏幕上仅仅生成了两个重叠的三角形,没有纹理,没有光照,也没有复杂的颜色变化。

本次作业在之前的基础上进行了全面升级,实现了一个基于以下技术的高清渲染器:

  • Blinn-Phong 光照模型
  • 重心坐标插值(颜色、法线、纹理坐标、观察空间位置)
  • Obj 材质库(.mtl)与贴图(Texture)加载
  • 凹凸贴图(Bump Mapping)与位移贴图(Displacement Mapping)

1. 模型加载与数据组织:OBJ_Loader

OBJ_Loader.h 中,加载器主要从 .obj 文件中读取并解析以下信息:

  • v:顶点位置坐标(Vertex Positions)
  • vt:顶点纹理 UV 坐标(Vertex Texture Coordinates)
  • vn:顶点法向量(Vertex Normals)
  • f:面数据(Faces),即顶点的组合索引(格式形如 v/vt/vn

Loader 内部维护了以下几个核心数据结构:

std::vector<Mesh> LoadedMeshes;          // 保存已有的网格列表。每个 Mesh 是一个独立的子网格,拥有自己独立的 Vertices、Indices 和 MeshMaterial
std::vector<Vertex> LoadedVertices;      // 全局顶点列表(打平了所有 Mesh 的所有顶点)
std::vector<unsigned int> LoadedIndices; // 全局已加载索引列表,保存了所有三角形对上面全局顶点列表的索引
std::vector<Material> LoadedMaterials;   // 从 .mtl 中加载的已解析材质列表

main.cpp 中,通过遍历 LoadedMeshes 里的每一个 Mesh 顺序取出顶点,并将它们构造成 Triangle 对象存入 TriangleList

for (auto mesh : Loader.LoadedMeshes)
{
    // 每次跨 3 个顶点取出一个三角形
    for (int i = 0; i < mesh.Vertices.size(); i += 3)
    {
        Triangle* t = new Triangle();
        for (int j = 0; j < 3; j++)
        {
            t->setVertex(j, Vector4f(mesh.Vertices[i+j].Position.X, mesh.Vertices[i+j].Position.Y, mesh.Vertices[i+j].Position.Z, 1.0));
            t->setNormal(j, Vector3f(mesh.Vertices[i+j].Normal.X, mesh.Vertices[i+j].Normal.Y, mesh.Vertices[i+j].Normal.Z));
            t->setTexCoord(j, Vector2f(mesh.Vertices[i+j].TextureCoordinate.X, mesh.Vertices[i+j].TextureCoordinate.Y));
        }
        TriangleList.push_back(t);
    }
}

因为在解析阶段,每一面所对应的顶点已经被扁平化(Flatten)顺序追加到了 mesh.Vertices 中,因此我们可以直接以 i += 3 的步长从顶点数组中依次获取非重叠的独立三角形(Triangle List 布局)。


2. Fragment Shader 的动态回调与注册

为了实现多种渲染模式的无缝切换,main.cpp 定义了一个函数包装器(函数指针):

std::function<Eigen::Vector3f(fragment_shader_payload)> active_shader = phong_fragment_shader;

其结构定义如下:

  • 输入参数fragment_shader_payload(包含当前片元插值属性的结构体)。
  • 返回值Eigen::Vector3f(表示该像素计算得到的最终 RGB 颜色,范围为 [0, 255])。
  • 动态绑定:根据命令行参数选择性绑定为:normal_fragment_shadertexture_fragment_shaderphong_fragment_shaderbump_fragment_shaderdisplacement_fragment_shader

设置完毕后,通过下述方法将着色器注册给光栅化器:

r.set_fragment_shader(active_shader);

这种设计在软件架构上将**光栅化逻辑(几何遍历与深度测试)着色算法逻辑(光照与材质计算)**彻底解耦。后续无论我们如何修改或增加片元着色器,都完全不需要改动光栅化器的底层核心渲染代码。


3. 光栅化与属性插值:rasterize_triangle

在执行 draw() 时,顶点的物理属性和坐标经历了多维变换,它们被传入到光栅化器:

3.1 观察空间坐标准备

在进入光栅化循环之前,需将顶点的 3D 原始坐标转换到**观察空间(相机空间 / View Space)**下,用作后续光照计算中光源方向和视线方向的基准:

std::array<Eigen::Vector4f, 3> mm {
        (view * model * t->v[0]),
        (view * model * t->v[1]),
        (view * model * t->v[2])
};

std::array<Eigen::Vector3f, 3> viewspace_pos;
std::transform(mm.begin(), mm.end(), viewspace_pos.begin(), [](auto& v) {
    return v.template head<3>();
});

3.2 像素属性插值

在先前的 Assignment 中,在计算三角形包围盒(Bounding Box)之后,仅需通过插值得到深度 zpz_p 用作 Z-Buffer 遮挡测试。

而在本次作业中,通过深度测试的像素还需要插值计算以下属性,并打包存入 fragment_shader_payload

  • interpolated_color:插值颜色。
  • interpolated_normal:插值法向量。
  • interpolated_texcoords:插值纹理坐标 (u,v)(u, v)
  • interpolated_shadingcoords:插值后的着色点在观察空间下的 3D 坐标(即 payload.view_pos)。

重心坐标计算

上述插值的基准是二维重心坐标(Barycentric Coordinates)。从几何视角看,重心坐标实质上是面积比例: 在一个大三角形 ABC\triangle ABC 内部选取一个点 PP,它与三个顶点相连,将大三角形分割成了三个小三角形。每个顶点对应的坐标分量,取决于其对边小三角形的面积占比

  • AAA_A:点 PP 与边 BCBC 围成的三角形面积
  • ABA_B:点 PP 与边 ACAC 围成的三角形面积
  • ACA_C:点 PP 与边 ABAB 围成的三角形面积

大三角形的总面积为 Atotal=AA+AB+ACA_{\text{total}} = A_A + A_B + A_C。点 PP 的重心坐标表示为 (α,β,γ)(\alpha, \beta, \gamma),其公式如下:

α=AAAA+AB+AC,β=ABAA+AB+AC,γ=ACAA+AB+AC\alpha = \frac{A_A}{A_A + A_B + A_C}, \quad \beta = \frac{A_B}{A_A + A_B + A_C}, \quad \gamma = \frac{A_C}{A_A + A_B + A_C}

在代码中通过二维向量叉积展开,计算方式如下:

static std::tuple<float, float, float> computeBarycentric2D(float x, float y, const Vector4f* v){
    float c1 = (x*(v[1].y() - v[2].y()) + (v[2].x() - v[1].x())*y + v[1].x()*v[2].y() - v[2].x()*v[1].y()) / (v[0].x()*(v[1].y() - v[2].y()) + (v[2].x() - v[1].x())*v[0].y() + v[1].x()*v[2].y() - v[2].x()*v[1].y());
    float c2 = (x*(v[2].y() - v[0].y()) + (v[0].x() - v[2].x())*y + v[2].x()*v[0].y() - v[0].x()*v[2].y()) / (v[1].x()*(v[2].y() - v[0].y()) + (v[0].x() - v[2].x())*v[1].y() + v[2].x()*v[0].y() - v[0].x()*v[2].y());
    float c3 = (x*(v[0].y() - v[1].y()) + (v[1].x() - v[0].x())*y + v[0].x()*v[1].y() - v[1].x()*v[0].y()) / (v[2].x()*(v[0].y() - v[1].y()) + (v[1].x() - v[0].x())*v[2].y() + v[0].x()*v[1].y() - v[1].x()*v[0].y());
    return {c1,c2,c3};
}

得到插值系数后,我们在更新 Z-Buffer 时对属性进行计算并回调 Shader:

if (zp < depth_buf[index]) {
    depth_buf[index] = zp;
    fragment_shader_payload payload(interpolated_color, interpolated_normal.normalized(), interpolated_texcoords, texture ? &*texture : nullptr);
    payload.view_pos = interpolated_shadingcoords;
    
    // 执行片元着色器并给屏幕像素上色
    auto pixel_color = fragment_shader(payload);
    set_pixel(Eigen::Vector2i(x, y), pixel_color);
}

4. 着色器(Shader)的具体实现

4.1 Normal Shader: normal_fragment_shader

  • 作用:不包含任何光照模型,纯粹用于直观调试顶点法线是否插值正确。
  • 公式:将法线三维分量 [1,1][-1, 1] 映射到 [0,1][0, 1] 范围输出为 RGB: Color=n+(1,1,1)T2×255\text{Color} = \frac{\mathbf{n} + (1, 1, 1)^T}{2} \times 255

4.2 Blinn-Phong Shader: phong_fragment_shader

Blinn-Phong Reflection

Blinn-Phong 模型将物体表面反射光线拆分为三个部分:环境光项 (Ambient)漫反射项 (Diffuse)高光项 (Specular)。 总反射光强公式为三者之和: L=La+Ld+LsL = L_a + L_d + L_s

① 环境光项 (Ambient Term)

用于简单填补背光面,防止面完全死黑,是一个假想的常数: La=kaIaL_a = k_a I_a

② 漫反射项 (Lambertian Diffuse Term)

光线照射到粗糙表面后均匀散射。其反射光强与观察视角无关Ld=kd(Ir2)max(0,nl)L_d = k_d \left( \frac{I}{r^2} \right) \max(0, \mathbf{n} \cdot \mathbf{l})

  • kdk_d:漫反射系数(物体本色 payload.color)。
  • Ir2\frac{I}{r^2}:到达着色点处的光能衰减(随距离平方反比衰减)。
  • nl\mathbf{n} \cdot \mathbf{l}:表面法线 n\mathbf{n} 与入射光方向 l\mathbf{l} 的夹角余弦,限制必须大于 0。

③ 高光项 (Specular Term)

当观察方向接近镜面反射方向时产生的刺眼亮斑。引入了半程向量 (Half Vector)h=v+lv+l\mathbf{h} = \frac{\mathbf{v} + \mathbf{l}}{\|\mathbf{v} + \mathbf{l}\|} Ls=ks(Ir2)max(0,nh)pL_s = k_s \left( \frac{I}{r^2} \right) \max(0, \mathbf{n} \cdot \mathbf{h})^p

  • ksk_s:高光反射系数(通常是一片亮白色的光斑)。
  • pp高光指数。值越大,高光光斑越小、越锐利;值越小,光斑越大、越模糊。

代码核心实现

for (auto& light : lights)
{   
    Eigen::Vector3f light_l = light.position - point;
    float r = light_l.squaredNorm();
    light_l.normalize();
    Eigen::Vector3f light_v = (eye_pos - point).normalized() ; 
    Eigen::Vector3f bisector = (light_l + light_v).normalized();
    
    Eigen::Vector3f ambient = ka.cwiseProduct(amb_light_intensity);
    Eigen::Vector3f diffuse = kd.cwiseProduct(light.intensity / r * std::max(0.0f, normal.dot(light_l)));
    Eigen::Vector3f specular = ks.cwiseProduct(light.intensity / r * std::pow(std::max(0.0f , normal.dot(bisector)), p));
    
    result_color += (ambient + diffuse + specular);
}

4.3 Texture Shader: texture_fragment_shader

  • 作用:将纯色 Blinn-Phong 升级为贴图 Blinn-Phong。
  • 不同点:漫反射系数 kdk_d 不再是固定的 payload.color,而是根据插值得到的纹理坐标 (u,v)(u, v),在绑定贴图上进行采样: kd=texture.getColor(u,v)k_d = \text{texture.getColor}(u, v)

4.4 Bump Shader: bump_fragment_shader

  • 作用:通过一张**高度图(Height Map)**来扰动像素的法线方向,从而欺骗人眼,使其在光照下呈现出表面凹凸的假象,但物体的几何形状(顶点位置)并没有发生改变。
  • 算法步骤
    1. 构建切线空间(TBN 矩阵):对于法线 n=(x,y,z)\mathbf{n} = (x, y, z),计算其正交的切线 t\mathbf{t} 和副切线 b\mathbf{b}t=(xyx2+z2,x2+z2,zyx2+z2)T,b=n×t\mathbf{t} = \left( \frac{x \cdot y}{\sqrt{x^2+z^2}}, \sqrt{x^2+z^2}, \frac{z \cdot y}{\sqrt{x^2+z^2}} \right)^T, \quad \mathbf{b} = \mathbf{n} \times \mathbf{t} TBN=[tbn]TBN = \begin{bmatrix} \mathbf{t} & \mathbf{b} & \mathbf{n} \end{bmatrix}
    2. 计算高度图的局部梯度(偏导数)dU=khkn(h(u+1/w,v)h(u,v))dU = k_h \cdot k_n \cdot \left( h(u + 1/w, v) - h(u, v) \right) dV=khkn(h(u,v+1/h)h(u,v))dV = k_h \cdot k_n \cdot \left( h(u, v + 1/h) - h(u, v) \right) 其中 h(u,v)h(u,v) 为贴图颜色向量的模长(高度),wwhh 分别为纹理图的宽高。
    3. 合成扰动法线: 在切线空间下的局部法线为 ln=(dU,dV,1)T\mathbf{l}_n = (-dU, -dV, 1)^T。 转换到观察空间下的扰动法线为: nperturbed=normalize(TBNln)\mathbf{n}_{\text{perturbed}} = \text{normalize}(TBN \cdot \mathbf{l}_n)
    4. 最终输出:在本作业的 Bump 模式实现中,直接将扰动后的法线映射为 RGB 颜色输出,用于直观地查看凹凸贴图的效果。

4.5 Displacement Shader: displacement_fragment_shader

  • 作用:位移贴图是真正物理意义上的凹凸。它不仅改变了法线方向,还真正沿着法线方向移动了顶点的 3D 空间坐标
  • 算法步骤
    1. 按照与 bump 相同的方式,利用高度图计算出高度梯度 dUdUdVdV,以及对应的扰动法线: nperturbed=normalize(TBNln)\mathbf{n}_{\text{perturbed}} = \text{normalize}(TBN \cdot \mathbf{l}_n)
    2. 点位置位移(核心不同):沿着原始法线方向偏移像素的三维着色点位置: pdisplaced=p+knnh(u,v)\mathbf{p}_{\text{displaced}} = \mathbf{p} + k_n \cdot \mathbf{n} \cdot h(u, v)
    3. 计算光照:将位移后的点位置 pdisplaced\mathbf{p}_{\text{displaced}} 和扰动后的法线 nperturbed\mathbf{n}_{\text{perturbed}} 代入完整的 Blinn-Phong 模型中进行局部光照着色计算,返回最终颜色。
  • 相比 Bump 的优势:由于改变了物体表面的实际几何位置,使得模型轮廓边缘也具有凹凸感,且自阴影和遮挡效果更加真实。