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_shader、texture_fragment_shader、phong_fragment_shader、bump_fragment_shader或displacement_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)之后,仅需通过插值得到深度 用作 Z-Buffer 遮挡测试。
而在本次作业中,通过深度测试的像素还需要插值计算以下属性,并打包存入 fragment_shader_payload:
interpolated_color:插值颜色。interpolated_normal:插值法向量。interpolated_texcoords:插值纹理坐标 。interpolated_shadingcoords:插值后的着色点在观察空间下的 3D 坐标(即payload.view_pos)。
重心坐标计算
上述插值的基准是二维重心坐标(Barycentric Coordinates)。从几何视角看,重心坐标实质上是面积比例: 在一个大三角形 内部选取一个点 ,它与三个顶点相连,将大三角形分割成了三个小三角形。每个顶点对应的坐标分量,取决于其对边小三角形的面积占比:
- :点 与边 围成的三角形面积
- :点 与边 围成的三角形面积
- :点 与边 围成的三角形面积
大三角形的总面积为 。点 的重心坐标表示为 ,其公式如下:
在代码中通过二维向量叉积展开,计算方式如下:
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
- 作用:不包含任何光照模型,纯粹用于直观调试顶点法线是否插值正确。
- 公式:将法线三维分量 映射到 范围输出为 RGB:
4.2 Blinn-Phong Shader: phong_fragment_shader

Blinn-Phong 模型将物体表面反射光线拆分为三个部分:环境光项 (Ambient)、漫反射项 (Diffuse) 和 高光项 (Specular)。 总反射光强公式为三者之和:
① 环境光项 (Ambient Term)
用于简单填补背光面,防止面完全死黑,是一个假想的常数:
② 漫反射项 (Lambertian Diffuse Term)
光线照射到粗糙表面后均匀散射。其反射光强与观察视角无关:
- :漫反射系数(物体本色
payload.color)。 - :到达着色点处的光能衰减(随距离平方反比衰减)。
- :表面法线 与入射光方向 的夹角余弦,限制必须大于 0。
③ 高光项 (Specular Term)
当观察方向接近镜面反射方向时产生的刺眼亮斑。引入了半程向量 (Half Vector):
- :高光反射系数(通常是一片亮白色的光斑)。
- :高光指数。值越大,高光光斑越小、越锐利;值越小,光斑越大、越模糊。
代码核心实现
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。
- 不同点:漫反射系数 不再是固定的
payload.color,而是根据插值得到的纹理坐标 ,在绑定贴图上进行采样:
4.4 Bump Shader: bump_fragment_shader
- 作用:通过一张**高度图(Height Map)**来扰动像素的法线方向,从而欺骗人眼,使其在光照下呈现出表面凹凸的假象,但物体的几何形状(顶点位置)并没有发生改变。
- 算法步骤:
- 构建切线空间(TBN 矩阵):对于法线 ,计算其正交的切线 和副切线 :
- 计算高度图的局部梯度(偏导数): 其中 为贴图颜色向量的模长(高度), 与 分别为纹理图的宽高。
- 合成扰动法线: 在切线空间下的局部法线为 。 转换到观察空间下的扰动法线为:
- 最终输出:在本作业的 Bump 模式实现中,直接将扰动后的法线映射为 RGB 颜色输出,用于直观地查看凹凸贴图的效果。
4.5 Displacement Shader: displacement_fragment_shader
- 作用:位移贴图是真正物理意义上的凹凸。它不仅改变了法线方向,还真正沿着法线方向移动了顶点的 3D 空间坐标。
- 算法步骤:
- 按照与
bump相同的方式,利用高度图计算出高度梯度 和 ,以及对应的扰动法线: - 点位置位移(核心不同):沿着原始法线方向偏移像素的三维着色点位置:
- 计算光照:将位移后的点位置 和扰动后的法线 代入完整的 Blinn-Phong 模型中进行局部光照着色计算,返回最终颜色。
- 按照与
- 相比 Bump 的优势:由于改变了物体表面的实际几何位置,使得模型轮廓边缘也具有凹凸感,且自阴影和遮挡效果更加真实。