Assignment 2

投影矩阵的重新定义

GAMES101 在 Lab 2 中调整了深度值的约定:

请注意我们是如何初始化 depth buffer 和注意 z values 的符号。为了方便同学们写代码,我们将 z 进行了反转,保证都是正数,并且越大表示离视点越远。

我的理解:

输入阶段:原点仍为相机位置,物体位于 z 负半轴,相机朝向 -z 方向。
投影之后:深度被映射到 [-1, 1],并且数值越大表示距离相机越远。

因此需要重新计算投影矩阵。下面几个参数含义保持不变:

  • rr:半宽;
  • tt:半高;
  • nn:近平面与原点距离;
  • ff:远平面与原点距离;
  • ratio=rt\mathrm{ratio} = \frac{r}{t}
  • eye_fov\mathrm{eye\_fov}:视野角。

那么,视锥拉正矩阵 M1M_1 保持不变,表示为:

[n0000n0000(n+f)nf0010]\begin{bmatrix} -n & 0 & 0 & 0 \\ 0 & -n & 0 & 0 \\ 0 & 0 & -(n+f) & -nf \\ 0 & 0 & 1 & 0 \end{bmatrix}

但是第二步的变换,在 +z+z 方向上,需要把近平面映射到 1-1,远平面映射到 11,所以可以列出方程:

Af+B=1An+B=1\begin{aligned} -Af + B &= 1 \\ -An + B &= -1 \end{aligned}

得到变换矩阵 M2M_2

[1r00001t00002fnf+nfn0001]\begin{bmatrix} \frac{1}{r} & 0 & 0 & 0 \\ 0 & \frac{1}{t} & 0 & 0 \\ 0 & 0 & \frac{-2}{f-n} & -\frac{f+n}{f-n} \\ 0 & 0 & 0 & 1 \end{bmatrix}

然后:

M2M1=[nr0000nt0000f+nfn2nffn0010]M_2 \cdot M_1 = \begin{bmatrix} \frac{n}{r} & 0 & 0 & 0 \\ 0 & \frac{n}{t} & 0 & 0 \\ 0 & 0 & -\frac{f+n}{f-n} & -\frac{2nf}{f-n} \\ 0 & 0 & -1 & 0 \end{bmatrix}

insideTriangle 函数

Lab 2 在 Lab 1 画线框三角形的基础上,加入了三角形的栅格化。课程给出的基本思路是:遍历像素,并判断像素点是否位于三角形内部。

对于像素点 GG 和三角形 ABCABC,按顺序计算三组叉乘:

AG×AB,BG×BC,CG×CA\vec{AG} \times \vec{AB}, \quad \vec{BG} \times \vec{BC}, \quad \vec{CG} \times \vec{CA}

如果三者同号,说明点 GG 位于三角形三条有向边的同侧,也就位于三角形内部。这里把落在三角形边上的点也视为内部点。

draw 函数

draw函数虽说是代码框架提供的,但是也详细读读

  • 首先接受了main函数传来的三个关于三角形的pos、ind、cols的 <int,Vector3f><int , Vector3f>map
  • 定义了f1``f2,用于将后续被映射到[-1,1]的z坐标再映射到正值(深度值)
  • 进入循环,实例化三角形,对于每一个三角形,先将三个点分别通过MVP矩阵,把模型自己的局部坐标,一步步变换到裁剪空间
  • 经过mvp转换后,模型已经映射到了[1,1]3[-1,1]^3这个正方体,但是我们最后显示的屏幕为width * height大小,因此需要下一步操作
vert.x() = 0.5*width*(vert.x()+1.0); // 转为[0,width]
vert.y() = 0.5*height*(vert.y()+1.0); // 转为[0,height]
vert.z() = vert.z() * f1 + f2;  // 转为[zNear , zFar]
  • 简而言之,这里的代码将NDC坐标[-1,1]Normalized Device Coordinates归一化设备坐标,转为屏幕坐标
  • 接着通过setVertexsetColor,对于前面实例化的三角形设置参数,然后调用rasterize_triangle函数

rasterize_triangle 函数

Bounding Box

为了避免每次渲染都遍历整个屏幕,可以先计算三角形的 bounding_box,用一个最小矩形框住当前三角形。

具体做法是分别找出三角形三个顶点的最小、最大 xxyy 坐标,然后只对这个矩形范围内的像素做逐点判断。

判断与绘制

绘制一个像素前,需要进行两步判断:

  1. 像素是否在三角形内部;
  2. 像素是否足够靠近相机,能够覆盖当前已经绘制的颜色。

第一个判断已经由 insideTriangle 完成。

第二个判断使用代码框架提供的 z-buffer 算法。这里维护了两个一维数组:

  • frame_buf:存储每个像素最终输出的颜色;
  • depth_buf:存储当前像素对应图元的深度值。

由于 Lab 2 中深度值越大表示离相机越远,所以深度更小的像素应该覆盖深度更大的像素。

代码中使用下面的公式把二维坐标转换为一维数组下标:

(height - 1 - y) * width + x

具体流程如下:

  1. 遍历当前三角形的 bounding_box
  2. 对每个像素点调用 insideTriangle
  3. 如果像素位于三角形内部,就计算插值得到的深度 z_interpolated
  4. 调用 get_index(int x, int y) 得到当前像素在缓冲区中的下标;
  5. 比较 z_interpolateddepth_buf[idx]
  6. 如果当前像素更近,就更新 frame_bufdepth_buf

由于此时还没有实现更复杂的着色算法,所以直接使用三角形对象的 getColor() 返回颜色即可。

完成这些步骤后,三角形已经可以正常显示,并且前后遮挡关系也正确。

Bonus 篇

从生成结果可以看出,三角形边缘存在明显锯齿。这是因为一个像素只能显示一种颜色,而三角形边界又不一定刚好穿过像素边界,最终就会产生生硬的阶梯状边缘。

因此,Bonus 部分需要实现 Super-Sampling Anti-Aliasing,也就是超采样抗锯齿。这里采用 2×22 \times 2 采样:把原本的一个像素划分成四个子采样点,分别判断它们是否被三角形覆盖,最后再把四个采样点的颜色平均为当前像素的最终颜色。

首先定义四个子采样点相对于当前像素左下角的偏移量:

std::vector<Eigen::Vector2f> pixel_offsets ={
    {0.25f , 0.25f},
    {0.25f , 0.75f},
    {0.75f , 0.25f},
    {0.75f , 0.75f}
};

这些 offset 分别指向四个子采样区域的中心。与此同时,还需要额外维护两个一维数组:

  • ssaa_frame_buf:长度为原本 frame_buf 的四倍,用于存储每个子采样点的颜色;
  • ssaa_depth_buf:长度为原本 depth_buf 的四倍,用于记录每个子采样点对应的深度。

不过,最终绘制到屏幕上的仍然是原始分辨率的像素。因此,frame_buf 仍然需要保留,用来存储四个子采样点平均之后的最终颜色;而深度测试则可以交给 ssaa_depth_buf 完成。

具体修改并不复杂。对于原本的每一个像素 (x,y)(x, y),之前只需要判断像素中心是否在三角形内部;现在则要遍历四个 offset,分别判断四个子采样点是否位于三角形内部。

每个子采样点都执行原本的深度测试与颜色更新逻辑,只是不再直接调用 set_pixel。等四个子采样点都处理完成后,再对它们的颜色求平均,并写入当前像素对应的 frame_buf

运行结果

之前:

之后:

可以看到,开启超采样之后,三角形边缘柔和了很多,锯齿感明显减弱。

同时也能观察到,frame_count 的递增速度大约变成了原来的 14\frac{1}{4}。这是因为每个像素都需要额外进行四次采样和深度测试,也正体现了超采样抗锯齿在效果之外所付出的性能代价。