遮挡
我们一般需要在屏幕上绘制多个三角形,那么这样就需要考虑三角形之间的遮挡问题了,最直观的解决方法就是先将最远的三角形绘制出来,再将比这个三角形离屏幕近的三角形绘制出来,将原来的远处三角形覆盖掉,这个方式和画家们画油画的过程很类似,而这个算法我们称之为画家算法,时间复杂度是$O(nlogn)$。
但是这个算法不能绘制如下的情况:

这三个三角形之间并没有一个固定的前后关系,他们两两之间都遮挡了对方一部分,而用画家算法就没办法绘制这种关系
Z-Buffer 深度缓冲
在图形学中人们为了解决这个问题引入了深度缓冲(Z-Buffer)这么一个算法,这个算法非常简单:我们不再对一个物体判断它的前后关系,而是对每个物体的像素进行判断前后关系,如果物体a的像素在物体b的像素前面,那么物体a的像素就会覆盖物体b的像素,而Z-Buffer通俗的说就是一个二维数组,这个二维数组存放着所有被绘制过的像素的深度值,如果有一个新的物体需要被绘制,那么我们需要通过Z-Buffer上面记录的深度值与这个新的物体的深度值进行比较(这个比较我们通常称为深度测试(Depth Test)),如果这个新的物体某个像素的深度值小于(或者大于)Z-Buffer中对应像素的深度值,那么Z-Buffer就会使用这个物体的深度值去更新Z-Buffer中的深度值并且将颜色值写入FrameBuffer中,否则直接丢弃这个像素(这个像素不需要被绘制了)。
FrameBuffer/ColorBuffer 我们在绘制的时候会将最后的像素的颜色结果存储在FrameBuffer中,而Z-Buffer中则存储的是像素的深度值, 最终使用FrameBuffer色进行渲染
1// 伪代码
2for(each triangle T)
3 for(each sample(x,y,z) in T)
4 if(z < z_buffer[x,y]) // closest sample so far
5 frame_buffer[x,y] = T.color // update color
6 z_buffer[x,y] = z // update depth
7 else
8 ; // do nothing
初始化的深度缓冲我们一般设置为无限大的值,下面是一个算法演示,时间复杂度是$O(n)$

Shading
Shading 着色是指根据物体表面的材质属性、光照条件以及观察者的角度,计算物体表面像素颜色的过程。
Blinn-Phong Reflection Model
我们这里介绍一个最简单的着色模型,Blinn-Phong Reflection Model。我们先看下面一个例子
我们可以看到这些茶杯被光源照亮,我们可以看到茶杯上的光照有这么几个特殊的现象
- Specular 高光,也就是特别亮的地方,类似于物理中的镜面反射
- Diffuse 漫反射,而漫反射就是物理中我们学过的漫反射,我们生活中看到的大多数的物体都是由慢射反导致的
- Ambient 环境光,不直接被光源照亮的地方,而被其他不会自发光的物体反射照亮的光照即间接光照,例如你晚上在没有灯光的地方并不是完全黑看不见的而是存在如月光这种导致的环境光
Shading Point
由于我们对物体的 Shading 实际上就是对物体的所有像素进行着色,那么对于一个需要着色的像素我们称为Shading Point,而对于一个 Shading Point 在放大"无限"倍后它是一个平面,也就是说我们将一个模型的某个点放大非常多倍之后我们使用一个平面模型来表示这个Shading Point 所以我们就可以定义这个点所在的平面的法线方向 $\mathbf{n}$ 以及光照方向$\mathbf{l}$,以及观察方向 $\mathbf{v}$,这些方向都是单位向量,当然我们还需要定义这个Shading Point的材质属性
- 颜色 Color
- 镜面指数 Shininess
- 等等

Shading is Local
Shading 我们只考虑这个Shading Point本身,不考虑其他以外的因素,如这个Shading Point是否被其他物体遮挡等等,如下面这副图的球后面应该是有阴影的,但是在Shading中我们不考虑

Diffuse Reflection
漫反射Diffuse Reflection 就是如果有一束光线,打到某一个点上,而光线会被均匀的放射到各个方向上去
但是怎么表示物体的明暗呢,这就是和一个着色点的单位面积接受多少的光能有关了,可以看到下面这副图,图1接受了所有的光能,而图2只接受了一部分,那我们就可以知道表面的亮度是和物体表面法线方向和光照方向的夹角有关的,而夹角我们可以使用点乘来计算。而详细来说是和能量密度有关了,想象一下如果一束能量恒定的光,以一个固定的光照区域照射一个表面如图1,而如果我们将表面变大并斜过45度让这个倾斜的表面全部被这个区域的光恰好照到,相对于没倾斜的表面,光照到这个倾斜的表面的面积变多了,那同样的一束阳光被“摊平”到了更大的面积上,单位面积分到的能量自然就少了。这个就是Lambert’s Cosine Law
如果我们假设一个点光源单位时间内发出的能量是恒定的,那么这个能量在空间中的传播遵循平方反比定律,在三维欧几里得空间中,平方反比定律是几何上的必然,想象之前那个点光源发出的光是一个能量恒定的球壳,而这个球壳会不断的变大(光能在空间中的传播),而它的能量会随这个球壳的表面积变大而均分,导致单位区域的能量衰减,这个能量衰减就遵循平方反比定律
所以我们可以得到漫反射的亮度公式
其中
- $L_d$ 漫反射亮度
- $k_d$ 漫反射系数
- $I$ 光照强度
- $r$ 光源到物体的距离
- $\mathbf{n}$ 物体的法线方向
- $\mathbf{l}$ 光照方向

作业2解析
作业框架中没有引入新的需要修改cmake的内容,所以我们直接按照上一次作业同样的方式修改框架就行了 首先作业要求我们将作业1的get_projection_matrix复制过来
1Eigen::Matrix4f get_projection_matrix(float eye_fov, float aspect_ratio, float zNear, float zFar)
2{
3 // TODO: Copy-paste your implementation from the previous assignment.
4 Eigen::Matrix4f projection;
5
6 return projection;
7}
我们照做
1Eigen::Matrix4f get_projection_matrix(float eye_fov, float aspect_ratio, float zNear, float zFar)
2{
3 Eigen::Matrix4f projection = Eigen::Matrix4f::Identity();
4
5 float n = -zNear;
6 float f = -zFar;
7
8 float angle = eye_fov * MY_PI / 180.0;
9 float t = tan(angle / 2) * fabs(zNear);
10 float r = aspect_ratio * t;
11 float l = -r;
12 float b = -t;
13
14 Eigen::Matrix4f persp_to_ortho;
15 persp_to_ortho << n, 0, 0, 0, 0, n, 0, 0, 0, 0, n + f, -n * f, 0, 0, 1, 0;
16
17 Eigen::Matrix4f ortho;
18 ortho << 2 / (r - l), 0, 0, -(r + l) / (r - l), 0, 2 / (t - b), 0, -(t + b) / (t - b), 0, 0,
19 2 / (n - f), -(n + f) / (n - f), 0, 0, 0, 1;
20
21 projection = ortho * persp_to_ortho;
22
23 return projection;
24}
首先我们实现bool insideTriangle(int x, int y, const Vector3f* _v)函数
而判断一个点是否在三角形内部我们使用第5节课中的叉乘算法
1static bool insideTriangle(int x, int y, const Vector3f* _v)
2{
3 Eigen::Vector3f p(x, y, 0.0f);
4
5 float results[3];
6
7 for(int i = 0; i < 3; i++){
8 Eigen::Vector3f a = _v[i];
9 Eigen::Vector3f b = _v[(i + 1) % 3];
10
11 Eigen::Vector3f ab = b - a;
12 Eigen::Vector3f ap = p - a;
13
14 results[i] = ab.x() * ap.y() - ab.y() * ap.x();
15 }
16
17 // 无论输入的三角形顶点是顺时针定义还是逆时针定义,都判断一下
18 return (results[0] > 0 && results[1] > 0 && results[2] > 0) ||
19 (results[0] < 0 && results[1] < 0 && results[2] < 0);
20}
接着我们实现三角形的光栅化算法,其中用到了重心坐标插值三角形内部的z值,我们介绍一下
1//Screen space rasterization
2void rst::rasterizer::rasterize_triangle(const Triangle& t) {
3 auto v = t.toVector4();
4
5 // 找到这个三角形的Bouding Box
6 float min_x = std::min({v[0].x(), v[1].x(), v[2].x()});
7 float max_x = std::max({v[0].x(), v[1].x(), v[2].x()});
8 float min_y = std::min({v[0].y(), v[1].y(), v[2].y()});
9 float max_y = std::max({v[0].y(), v[1].y(), v[2].y()});
10
11 int x_start = static_cast<int>(std::floor(min_x));
12 int x_end = static_cast<int>(std::ceil(max_x));
13 int y_start = static_cast<int>(std::floor(min_y));
14 int y_end = static_cast<int>(std::ceil(max_y));
15
16 // 遍历Bouding Box中的所有像素
17 for (int x = x_start; x < x_end; x++) {
18 for (int y = y_start; y < y_end; y++) {
19
20 // 检查像素中心 (x+0.5, y+0.5) 是否在三角形内
21 if (insideTriangle(static_cast<float>(x) + 0.5f, static_cast<float>(y) + 0.5f, t.v)) {
22
23 // 计算插值深度 z
24 auto [alpha, beta, gamma] = computeBarycentric2D(static_cast<float>(x) + 0.5f, static_cast<float>(y) + 0.5f, t.v);
25 float w_reciprocal = 1.0 / (alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w());
26 float z_interpolated = alpha * v[0].z() / v[0].w() + beta * v[1].z() / v[1].w() + gamma * v[2].z() / v[2].w();
27 z_interpolated *= w_reciprocal;
28
29 // 深度测试
30 int index = get_index(x, y);
31 if (z_interpolated < depth_buf[index]) {
32 // 更新深度缓冲区
33 depth_buf[index] = z_interpolated;
34 set_pixel(Eigen::Vector3f(x, y, 1.0f), t.getColor());
35 }
36 }
37 }
38 }
39}
在三角形 $ABC$ 所在的平面内,任何一个点 $P$ 都可以表示为三个顶点坐标的线性组合:
$$P = \alpha A + \beta B + \gamma C$$其中 $\alpha + \beta + \gamma = 1$。这三个系数 $(\alpha, \beta, \gamma)$ 就是点 $P$ 的重心坐标。 而对于三角形内部的任何一个点$P$,它与三角形三个顶点之间进行连线可以组成三个三角形,而重心坐标 $(\alpha, \beta, \gamma)$ 与面积的关系如下:
$$\alpha = \frac{\text{Area}(P, B, C)}{\text{Area}(A, B, C)}$$$$\beta = \frac{\text{Area}(P, A, C)}{\text{Area}(A, B, C)}$$$$\gamma = \frac{\text{Area}(P, A, B)}{\text{Area}(A, B, C)}$$对于一个三角形的面积我们可以使用这个三角形的两个边组成的向量进行叉乘然后求模再除以2获得,有了重心坐标我们就可以通过插值的方式获取三角形内部的z值了,
你可能会问既然有了 $\alpha, \beta, \gamma$,为什么不直接写成 $z = alpha*v0.z + beta*v1.z + gamma*v2.z$
这是因为透视投影是非线性的,在 3D 空间中是线性的属性,经过透视投影变换到 2D 屏幕空间后,就不再是线性的了,如果你直接在屏幕空间用重心坐标插值,会导致纹理扭曲或深度错误,为了修正这个问题,我们需要利用齐次坐标中的 $w$ 分量进行透视矫正插值。在屏幕空间插值任何属性 $I$,其正确公式为:
所以你会在代码中看到这样的公式,最后你会得到这张图
我们还可以修改三个顶点的颜色,使用顶点的颜色进行插值得到内部点的颜色

1void rst::rasterizer::rasterize_triangle(const Triangle& t) {
2 auto v = t.toVector4();
3
4 // 找到这个三角形的Bouding Box
5 float min_x = std::min({v[0].x(), v[1].x(), v[2].x()});
6 float max_x = std::max({v[0].x(), v[1].x(), v[2].x()});
7 float min_y = std::min({v[0].y(), v[1].y(), v[2].y()});
8 float max_y = std::max({v[0].y(), v[1].y(), v[2].y()});
9
10 int x_start = static_cast<int>(std::floor(min_x));
11 int x_end = static_cast<int>(std::ceil(max_x));
12 int y_start = static_cast<int>(std::floor(min_y));
13 int y_end = static_cast<int>(std::ceil(max_y));
14
15 // 遍历Bouding Box中的所有像素
16 for (int x = x_start; x < x_end; x++) {
17 for (int y = y_start; y < y_end; y++) {
18
19 // 检查像素中心 (x+0.5, y+0.5) 是否在三角形内
20 if (insideTriangle(static_cast<float>(x) + 0.5f, static_cast<float>(y) + 0.5f, t.v)) {
21
22 // 计算插值深度 z
23 auto [alpha, beta, gamma] = computeBarycentric2D(static_cast<float>(x) + 0.5f, static_cast<float>(y) + 0.5f, t.v);
24 float w_reciprocal = 1.0 / (alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w());
25 float z_interpolated = alpha * v[0].z() / v[0].w() + beta * v[1].z() / v[1].w() + gamma * v[2].z() / v[2].w();
26 z_interpolated *= w_reciprocal;
27
28 // 深度测试
29 int index = get_index(x, y);
30 if (z_interpolated < depth_buf[index]) {
31 // 更新深度缓冲区
32 depth_buf[index] = z_interpolated;
33 // 对颜色插值得到内部点的颜色,由于颜色与空间信息无关所以不需要齐次坐标修正
34 Eigen::Vector3f interpolated_color = alpha * t.getColor(0) + beta * t.getColor(1) +gamma * t.getColor(2);
35 set_pixel(Eigen::Vector3f(x, y, 1.0f), interpolated_color);
36 }
37 }
38 }
39 }
40}
提高项

实现 SSAA 我们需要维护两个新的缓冲区,一个新的更大的Z-Buffer,一个新的更大的FrameBuffer,而题目要求我们对每个像素进行2*2的采样,所以我们的需要一个比原来缓冲区大4倍的缓冲区,
1namespace rst
2{
3 class rasterizer
4 {
5 ...
6 std::vector<float> sample_depth_buf;
7
8 std::vector<Eigen::Vector3f> sample_frame_buf;
9 ...
10 };
11}
在初始化的时候
1rst::rasterizer::rasterizer(int w, int h) : width(w), height(h)
2{
3 frame_buf.resize(w * h);
4 sample_frame_buf.resize(w * h * 4);
5 depth_buf.resize(w * h);
6 sample_depth_buf.resize(w * h * 4);
7}
clear的时候
1void rst::rasterizer::clear(rst::Buffers buff)
2{
3 if ((buff & rst::Buffers::Color) == rst::Buffers::Color)
4 {
5 std::fill(frame_buf.begin(), frame_buf.end(), Eigen::Vector3f{0, 0, 0});
6 std::fill(sample_frame_buf.begin(), sample_frame_buf.end(), Eigen::Vector3f{0, 0, 0});
7 }
8 if ((buff & rst::Buffers::Depth) == rst::Buffers::Depth)
9 {
10 std::fill(depth_buf.begin(), depth_buf.end(), std::numeric_limits<float>::infinity());
11 std::fill(sample_depth_buf.begin(), sample_depth_buf.end(), std::numeric_limits<float>::infinity());
12 }
13}
还需要定义一个获取子像素在新的Buffer中位置的函数
1int rst::rasterizer::get_sample_index(int x, int y, int sample_id)
2{
3 return get_index(x, y) * 4 + sample_id;
4}
最后rasterize_triangle新的实现,解析看注释
1void rst::rasterizer::rasterize_triangle(const Triangle& t) {
2 auto v = t.toVector4();
3
4 // 对原像素内部定义新的4个采样点的偏移量
5 static const std::array<Eigen::Vector2f, 4> sample_offsets = {
6 Eigen::Vector2f(0.25f, 0.25f),
7 Eigen::Vector2f(0.75f, 0.25f),
8 Eigen::Vector2f(0.25f, 0.75f),
9 Eigen::Vector2f(0.75f, 0.75f)
10 };
11
12 // 找到这个三角形的Bouding Box
13 float min_x = std::min({v[0].x(), v[1].x(), v[2].x()});
14 float max_x = std::max({v[0].x(), v[1].x(), v[2].x()});
15 float min_y = std::min({v[0].y(), v[1].y(), v[2].y()});
16 float max_y = std::max({v[0].y(), v[1].y(), v[2].y()});
17
18 int x_start = std::max(0, static_cast<int>(std::floor(min_x)));
19 int x_end = std::min(width, static_cast<int>(std::ceil(max_x)));
20 int y_start = std::max(0, static_cast<int>(std::floor(min_y)));
21 int y_end = std::min(height, static_cast<int>(std::ceil(max_y)));
22
23 // 遍历Bouding Box中的所有像素
24 for (int x = x_start; x < x_end; x++) {
25 for (int y = y_start; y < y_end; y++) {
26 // 优化标志位,如果当前像素没有任何子采样点被更新,就跳过最终的颜色合并阶段
27 bool pixel_updated = false;
28 // 遍历子像素
29 for (int sample_id = 0; sample_id < static_cast<int>(sample_offsets.size()); ++sample_id) {
30 // 对原像素进行偏移,采样子像素
31 const float sample_x = static_cast<float>(x) + sample_offsets[sample_id].x();
32 const float sample_y = static_cast<float>(y) + sample_offsets[sample_id].y();
33
34 // 判断子像素是否在三角形内
35 if (!insideTriangle(sample_x, sample_y, t.v)) {
36 continue;
37 }
38
39 // 对子像素的z进行插值
40 auto [alpha, beta, gamma] = computeBarycentric2D(sample_x, sample_y, t.v);
41 float w_reciprocal = 1.0f / (alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w());
42 float z_interpolated = alpha * v[0].z() / v[0].w() + beta * v[1].z() / v[1].w() + gamma * v[2].z() / v[2].w();
43 z_interpolated *= w_reciprocal;
44
45 // 获得子像素在新Buffer中的索引
46 int sample_index = get_sample_index(x, y, sample_id);
47 // 对子像素进行深度测试
48 if (z_interpolated < sample_depth_buf[sample_index]) {
49 // 对子像素进行Z-Buffer更新
50 sample_depth_buf[sample_index] = z_interpolated;
51 // 对子像素更新FrameBuffer
52 sample_frame_buf[sample_index] = t.getColor();
53 // 更新状态
54 pixel_updated = true;
55 }
56 }
57
58 //将 2x2 子采样点的颜色融合成最终的像素颜色
59 if (pixel_updated) {
60 Vector3f color = Vector3f::Zero();
61 float min_depth = std::numeric_limits<float>::infinity();
62 // 遍历四个子像素
63 for (int sample_id = 0; sample_id < 4; ++sample_id) {
64 const int sample_index = get_sample_index(x, y, sample_id);
65 // 累加 4 个子采样点的颜色
66 color += sample_frame_buf[sample_index];
67 // 选出 4 个子采样点中最浅的深度,更新主深度缓冲区
68 min_depth = std::min(min_depth, sample_depth_buf[sample_index]);
69 }
70
71 // 更新整个屏幕的深度缓冲和颜色缓冲
72 depth_buf[get_index(x, y)] = min_depth;
73 set_pixel(Eigen::Vector3f(x, y, 1.0f), color / 4.0f);
74 }
75 }
76 }
77}
可以看到使用SSAA(下方)和不使用SSAA(上方)的区别

