Github TinyRenderer渲染器课程实践记录: 深度测试 / 纹理映射
Abstract
上一节:三角形光栅化
z-buffer 深度缓存技术。
Reference :
从一个简单场景着手
如上图,米白色的底面为投影屏幕,空中是三个互相交错的三角形,而相机俯览地将这些三角形投射到屏幕上:
注意这几个三角形呈现了一种相对复杂的覆盖关系,若用上章那样的画家算法Painter's algorithm将会导致错误的覆盖顺序。
暂时丢弃维度 \(z\) ,考虑一下 "Y-buffer"
想象一下,从此场景的侧面 --- 平行于投影平面的方向看,场景会变成这个样子:
现在我们将这几个三角形看作三条线。"Y-buffer"的原理为,分别绘制红、绿、蓝线,每次从场景左端开始用一条线扫描到右端。若此条线与扫描线 \(x = a\) 交点的 \(y\) 值大于上一次更新过的 ybuffer[a] ,则说明该交点在视觉上对当前扫描线对应的已存在投影点存在覆盖关系,那么当前投影点应更新为此交点的颜色。
不难想象,所有计算完成后,图像会是一条与平面同宽,高只有1的颜色线,因为这是二维投影到一维,不是吗?但我们但屏幕分辨率都比较高,这样看起来费眼睛,于是可以将Render的高设为16 pixels来让观察更为轻松:
void rasterize(Vec2i p0, Vec2i p1, TGAImage &image, TGAColor color, int ybuffer[]) {
// 修正扫描顺序
if (p0.x>p1.x) {
std::swap(p0, p1);
}
for (int x=p0.x; x<=p1.x; x++) {
float t = (x-p0.x)/(float)(p1.x-p0.x);
int y = p0.y*(1.-t) + p1.y*t;
if (ybuffer[x]<y) {
ybuffer[x] = y;
image.set(x, 0, color);
}
}
}
main 函数中,调用前需先将 ybuffer 的深度初始化为 \(-infinity\) ,这样第一轮扫描就可以产生正确的覆盖关系。
TGAImage render(width, 16, TGAImage::RGB);
int ybuffer[width];
for (int i=0; i<width; i++) {
ybuffer[i] = std::numeric_limits<int>::min();
}
rasterize(Vec2i(20, 34), Vec2i(744, 400), render, red, ybuffer); // 第一轮扫描
rasterize(Vec2i(120, 434), Vec2i(444, 400), render, green, ybuffer); // 第二轮扫描
rasterize(Vec2i(330, 463), Vec2i(594, 200), render, blue, ybuffer); // 第三轮扫描
// 加宽
for (int i=0; i<width; i++) {
for (int j=1; j<16; j++) {
render.set(i, j, render.get(i, 0));
}
}
渲染出来的颜色条与场景中线之间的遮挡关系能清晰的呈现出对应关系:
回到3D世界
理解2D "Y-buffer" 原理后,自然能将结论推广到3D空间。
先提前明白一些事情。屏幕是2D的所以一般用两个维度的容器来存放屏幕像素,但是可以用一维容器来存放,只需自行计算索引:
已知坐标,求像素索引:
int* zbuffer = new int[width*height];
int index = x + y*width;
已知像素索引,求对应二维坐标:
int x = index % width;
int y = index / width;
现在无外乎多了一个维度 \(z\) ,但我们看问题的角度依然不变。
还记得 "Y-buffer" 例子的计算中,直线上点的 \(y\) 值是由线性插值得来的。扩展到3D情形,三角形面片内部点的 \(z\) 值同样由线性插值得来,只是这种线性插值又叫做重心坐标。
体现在代码上便是,重心坐标不仅能判断点是否在三角形内,还能完成三角形内点信息的插值:
Vec3f P;
for (P.x=bboxmin.x; P.x<=bboxmax.x; P.x++) {
for (P.y=bboxmin.y; P.y<=bboxmax.y; P.y++) {
Vec3f bc_screen = barycentric(pts, P);
if (bc_screen.x<0 || bc_screen.y<0 || bc_screen.z<0) continue;
P.z = 0;
for (int i=0; i<3; i++) P.z += pts[i][2]*bc_screen[i];
if (zbuffer[int(P.x+P.y*width)]<P.z) {
zbuffer[int(P.x+P.y*width)] = P.z;
image.set(P.x, P.y, color);
}
}
}
采用 z-buffer 的结果就是,上一节嘴部的错误绘制被修正:
到了这里,实际上我们已经实现了基于正交投影的3D着色,且光照模型采用的是 Flat Shading平面着色。这种着色技术容易理解,计算复杂度低,但随之而来但便是不算精细的着色效果。
扩展:纹理映射
虽然人脸模型有了合适的光照,正确的顶点遮挡,但是看起来还是太单调了。能给它表面蒙上一层皮肤就好了。
左边是我们之前渲染的头部模型的预览,右边是其对应的纹理图片。直觉上便是将这张图片每个坐标一一对应地贴在模型上不是吗?
还记得之前渲染的基于 Flat Shading 的人脸吗,白溜溜的,随着光线角度产生明暗变化,因为我们代码默认所有点的着色颜色为纯白色:
triangle(pts, zbuffer, image, TGAColor(intensity*255, intensity*255, intensity*255, 255));
也即,白色根据光照强度的衰减来产生明暗效果。纹理映射的计算其实大同小异,对于每个三角形面片,顶点在纹理图片上取得对应颜色,再为面片内部点进行颜色的插值即可。
小障碍:obj文件
要做到纹理映射,首先要能理解如何解析obj文件。这里有个简单的例子:一个正方体木箱。
用文本编辑器查看此obj文件的内容,会发现分为开头为 v 、vt 、f 的三个数据区域。(我们选取的例子中没有 vn 开头的数据,vn 代表顶点的法向量,在我们已知的 Flat Shading 下暂时用不到)
# 8 vertices
v -1.000000 -1.000000 1.000000
v -1.000000 -1.000000 -1.000000
v 1.000000 -1.000000 -1.000000
v 1.000000 -1.000000 1.000000
v -1.000000 1.000000 1.000000
v -1.000000 1.000000 -1.000000
v 1.000000 1.000000 -1.000000
v 1.000000 1.000000 1.000000
# 4 uvs
vt 0.000000 0.000000
vt 1.000000 0.000000
vt 1.000000 1.000000
vt 0.000000 1.000000
# 6 faces
f 5/1 6/2 2/3 1/4
f 6/1 7/2 3/3 2/4
f 7/1 8/2 4/3 3/4
f 8/1 5/2 1/3 4/4
f 1/1 2/2 3/3 4/4
f 8/1 7/2 6/3 5/4
首先,v 开头的数据自然为木箱模型的八个顶点 \((x, y, z)\),不信你可以数数。
其次,vt 开头的数据代表纹理的 \(uv\) 坐标,别被它吓倒,我相信你一听就懂:
-
\(uv\) 坐标范围为 \([0, 1]\)
-
\(uv\) 的意义为纹理图片的采样比例。一张纹理图片被四个 \(uv\) 点所包围,图片上每个像素的颜色都能用一组 \(uv\) 与图片宽高的乘积算出:
最后,f 开头的向量表示模型的所有面片,每一条表示一个面片,它同时起到映射的功能。
其格式为 \(f = v_1/vt_1,\ \ v_2/vt_2,\ \ v_3/vt_3,\ ...,\ v_n/vt_n\ \ ,n \ge 3\) ;每个分量为一个 v/vt,同时代表面片的一个顶点;n决定了模型如何描述一个面片的边数。
拿其中一条来举例:
# 在这里,易知 n = 4,即该模型定义一个面片为四边形。
f 8/1 7/2 6/3 5/4
它表达什么意思呢?该面片的四个顶点分别对应模型的第8、7、6、5个顶点;
而模型的第8、7、6、5个顶点又分别对应第1、2、3、4个 \(uv\) 点,也即:
f1 = v 1.000000 1.000000 1.000000 -> vt 0.000000 0.000000
f2 = v 1.000000 1.000000 -1.000000 -> vt 1.000000 0.000000
f3 = v -1.000000 1.000000 -1.000000 -> vt 1.000000 1.000000
f4 = v -1.000000 1.000000 1.000000 -> vt 0.000000 1.000000
这条映射带来的效果即为,由对应 \(uv\) 算出实际纹理坐标对应的颜色,然后着色到该 \(uv\) 对应的顶点上。看起来就像这样(请忽略我糟糕的ps技术):
有了足够的理解后,我们可以解析 obj 文件,然后将其对应纹理贴上去!
由于多出来了纹理坐标,需为原Model类增加一个存储uv坐标的数组,将存储面片的容器类型改一下(要为每个顶点配对一个uv点寻址顺序)。另外便是增加取得uv坐标的函数:
有了纹理坐标,便可以为点着色了。我首先用了两种算法进行插值着色,第一种为算出面片的三个顶点对应的纹理颜色,然后在其内部对这些颜色进行插值:(虚线代表真实映射,箭头线代表插值得到)
用这种方法渲染出来的头部是这样的:(左边未加光源)
看起来有模有样了,但是你会发现似乎少了很多细节,糊糊的。那是因为三角面片内部的颜色是估算出来的,不是实际颜色。
第二种算法为算出面片三个顶点对应的 \(uv\) 坐标,然后在其内部对 \(uv\) 进行插值,然后再获取准确的颜色:
因为多了一个步骤,也就是再对内部进行一次 \(uv\) 插值,然后再得到颜色,这样硬算出来的真实坐标取得的颜色才会更准确:
非常棒,现在纹理映射变得非常精确!
这是我提交的本章代码 github