pan64271的博客

未经审视的人生,是不值得过的。——苏格拉底

0%

babyrasterizer-手写软光栅化渲染器踩坑记

前言

自学图形学也算是有一段时间了,看到各种大佬似乎在入门后都会选择手写一个软渲染器的样子,于是我也试着跟风来写了一下(毕竟这东西真的很酷)。

在调查了一番后,我发现很多大佬都推荐跟着 tinyrenderer 这个项目来写,于是我也把它当作了一个很重要的参考。当然,这里面很多知识点讲解得其实比较粗略,新手想直接看这个就完全搞懂得话还是有难度的,所以最好还是搭配闫老师的课程 GAMES101 食用。

最终的成品放在了 github 的这个仓库上:pan64271’s babyrasterizer,可以看到其中挺多部分还是参考的 tinyrenderer 这个项目,比如 model.hmodel.cppgeometry.hgeometry.cpptgaimage.htgaimage.cpp 这些基础的模型加载、图像读取保存、线性代数的内容就基本上是照搬过来的。当然我也还是在照搬的基础上做了一些改进或者说优化的,比如线性代数的部分我就添加了一些让图形学部分的代码更简洁的内容。

说了这么多废话,下面就回归正题,开始记录一下写这个 babyrasterizer 的过程中踩过的一些坑吧。

坑点

坑点一:法向量的转换

随着物体的旋转,一个三角形面的法向量也是会跟着旋转的,所以当对物体使用了 Model 这个变换时,我们也需要对其相应的每个面的法向量做出一定的变换。

而这个变换并不是直接把 Model 矩阵乘到法向量 n 上就行了(这就是我途中明明知道但还是犯了的错误),而是需要乘上 Model 矩阵的逆矩阵的转置矩阵,即 $(Model^{-1})^T$,理由如下:

设原平面上的一个向量为 $\vec{a}$,则有 $\vec{n}^T \cdot \vec{a} = 0$ (此处的点为矩阵乘法),经过 Model 矩阵转换后,$\vec{a}$ 变成了 $Model \cdot \vec{a}$,要想经过转换 $T$ 后的 $T \cdot \vec{n}$ 与 $Model \cdot \vec{a}$ 垂直,则需要满足 $(T \cdot \vec{n})^T \cdot (Model \cdot \vec{a}) = 0$,亦即 $(\vec{n}^T \cdot T^T) \cdot (Model \cdot \vec{a}) = 0$,即 $\vec{n}^T \cdot (T^T \cdot Model) \cdot \vec{a} = 0$。

如果有 $T^T \cdot Model = I$ 的话,那么上面的条件显然成立,故 $T = (Model^{-1})^T$。

坑点二:深度的透视矫正,z = z / w / w

常规的实现下似乎是在做完透视除法后再进行 viewport 转换,然后再把坐标 $(x, y, z, 1)$ 给变成 $(x, y, z/w, 1/w)$ 以方便后续的透视矫正。

在我的实现中,为了方便使用这个 $w$ 值(因为做完透视除法这个 $w$ 值就消失了),我是在做完了 viewport 转换后才进行透视除法的。但是在做完透视除法后对 $z$ 坐标应该再除以一次 $w$ 才能得到透视矫正需要的 $z/w$,在这里我就忘了要多除以一次 $w$,搞得最后出来的结果很诡异。

坑点三:MSAA 需要为每个子采样点记录深度与颜色值

一开始我以为直接算出像素中心点的颜色,然后再看看当前三角形对像素的子采样点的覆盖情况,根据覆盖情况来对这个颜色设为几分之几就行了,也就是说我没有考虑一个像素的子采样点分别被几个不同的三角形覆盖的情况,可想而知出来的效果肯定是不对的,每个三角形的边缘都被较暗的像素给区别了出来:

MSAA_error.png

后来在群里的大佬的提醒下,才知道了原来要对每个像素的子采样点都维护深度值与颜色值,等所有三角形都对这些子采样点着色完后,才计算每个像素的颜色。半懂不懂的情况下就去实战果然还是不太行啊。

坑点四:切线空间法线贴图

一开始我是用的每个三角形面的法向量来作为 TBN 矩阵的第三维,结果出来的结果有点诡异:

tangent_nm_error.png

后来看了下 tinyrenderer 项目里关于切线空间法向量贴图的对应源码,法线这个模型的法线贴图要用对应片段的法向来做 TBN 矩阵的第三维,问了下群里的大佬才知道原来这两种做法都有,具体是哪种则是取决于生成法线贴图时选了哪种。

其他

最后稍微写一下一些实现上的决策以及遗憾点吧。

首先是齐次坐标裁剪。这部分我只实现了 z 轴上的裁剪,因为我觉得 x 轴以及 y 轴方向上的裁剪的话,直接在做三角形光栅化求 bounding box 时,把超出屏幕的部分去掉就好了,只需要多做四次比较,感觉这样子性能上反而会更佳。

然后是阴影。目前的阴影做的还只是跟着 tinyrender 项目做的一个很初步的平行光投影而已,而且也没有做到确保视锥体的范围都落到阴影贴图内。这个 bug 就等将来学深入了一点再来修吧。

最后是 SSAO。这个的话,说实话我暂时还没能完全理解,所以就还没实现。而且似乎 SSAO 和 MSAA 兼容不太好的样子,如果要加上 SSAO 的话,那可能就要把抗锯齿的方法也改掉,总之就会是一个大改吧。不过稍微看了下 LearnOpenGL 里的延迟渲染的部分似乎还挺有趣的样子,也是等日后学到了再来改进吧。

最后的最后,就把写了但没能集成到 babyrasterizer 中的 Bresenham 画线算法的代码放上来吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void line(int x0, int y0, int x1, int y1, TGAImage &image, const TGAColor &color) {
bool steep = false;
if (std::abs(x0 - x1) < std::abs(y0-y1)) {
std::swap(x0, y0);
std::swap(x1, y1);
steep = true;
}
if (x0 > x1) {
std::swap(x0, x1);
std::swap(y0, y1);
}
int dx = x1 - x0;
int dy = y1 - y0;
int yincr = (y1>=y0 ? 1 : -1);
int de = 2*std::abs(dy);
int e = 0;

int y = y0;
for (int x = x0; x <= x1; x++) {
if (steep) {
image.set(y, x, color);
} else {
image.set(x, y, color);
}
e += de;
if (e > dx) {
y += yincr;
e -= 2*dx;
}
}
}

欢迎关注我的其它发布渠道