渲染管线与空间变换细节
各个空间与对应的变换
每个3D模型都有自己的局部空间(Local Space)。空间拥有自己的局部坐标系,定义了自己的原点位置。
经过模型变换(Model Transformation)后,应用model矩阵变为世界空间(World Space)。具体的变换过程取决于我们想把模型放在世界的哪个位置。
经过观察变换(View Transformation)后,应用view矩阵变为视图空间(View Space)。主要为了实现视角变换(摄像机位置的改变)
经过投影(Projection)后,应用projection矩阵变为裁剪空间(Clip Space)。这一步将视锥进行线性变换,裁剪视锥外的区域,存储压缩信息,便于后续变换到NDC。
经过齐次除法后,变为NDC(Normalized Device Coordinates)。这一步变换到一个范围为[-1,1]的正方体中。
经过视口变换(Viewport Transformation)后,变为屏幕空间(Screen Space)。将NDC坐标映射到[0, 屏幕宽度] 和 [0, 屏幕高度]。
World Space与View Space
这两个空间的区别仅在于摄像机位置不同,导致两个坐标系不同。在GAMES101的作业3中大量使用了View Space坐标来计算各种插值、Blinn-Phong模型等,这是因为作业3中固定了摄像机视角,View Space坐标更方便。
而在视角可能变化的情况中,上述计算更多会使用世界坐标。
在作业3的draw函数中:
//仅应用view和model矩阵,因此mm中是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;
//截断得到三维的view space顶点坐标
std::transform(mm.begin(), mm.end(), viewspace_pos.begin(), [](auto& v) {
return v.template head<3>();
});
//传入光栅化
rasterize_triangle(newtri, viewspace_pos);
截取的这一段展示了draw()
如何得到View Space坐标。如果仅应用model矩阵,就可以得到World Space坐标。
由于光照计算需要模拟三维空间的物理规律,插值计算也需要符合三维下的几何特征,这些计算都需要用这两个坐标中的一个进行。
这里同样需要强调一下:world space和view space坐标在计算中作用很接近。以插值计算为例,插值计算需要计算重心坐标,而重心坐标的计算是根据NDC(或者是screnn space)下的坐标计算的。那么我们需要将来自world space或view space的属性值也变换到NDC/screen space,再进行插值,再乘回来(还原到world space/view space),这个过程称之为透视矫正。具体步骤如下:
//这里通过screen space的中心点坐标(x,y)计算重心坐标。
auto [alpha, beta, gamma] = computeBarycentric2D(x, y, t.v);
// 这里是对映射后的z做插值,而w是view space的Z,代表压缩程度
// Z是对真实的Z的插值,而zp是对映射后的[0.1,50]z的插值
//此处Z是便于后续还原到
float Z = 1.0 / (alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w());
// z_buffer插值
float zp = alpha * v[0].z() / v[0].w() + beta * v[1].z() / v[1].w() + gamma * v[2].z() / v[2].w();
zp *= Z;
// color插值
Vector3f interpolated_color = alpha * t.color[0] / v[0].w() + beta * t.color[1] / v[1].w() + gamma * t.color[2] / v[2].w();
// normal插值
Vector3f interpolated_normal = alpha * t.normal[0] / v[0].w() + beta * t.normal[1] / v[1].w() + gamma * t.normal[2] / v[2].w();
// texcoords插值
Vector2f interpolated_texcoords = alpha * t.tex_coords[0] / v[0].w() + beta * t.tex_coords[1] / v[1].w() + gamma * t.tex_coords[2] / v[2].w();
// shadingcoords插值
Vector3f interpolated_shadingcoords = alpha * view_pos[0] / v[0].w() + beta * view_pos[1] / v[1].w() + gamma * view_pos[2] / v[2].w();
// std::cout << "v[0]: " << v[0] << std::endl;
interpolated_color *= Z;
interpolated_normal *= Z;
interpolated_texcoords *= Z;
interpolated_shadingcoords *= Z;
Clip Space
一直以来我对Clip Space的理解都有误区。实际上经过投影后的Clip Space仅仅是一个中间状态,它仅做线性变换,将视锥外不可见的空间裁剪掉,并将透视关系相关的信息以 w_{clip}的形式存储。
后续只需要进行齐次除法,就可以实现完整的透视投影。或者可以说projection矩阵是为了完成投影的预处理计算,而齐次除法将计算结果展现到NDC中,齐次除法本身就是非线性变换,也是透视矫正所需要矫正的部分。
NDC和Screen Space
经过齐次除法后,变换结果被限制在 {[-1,1]}^{3} 的范围内,这个结果就是NDC。再将NDC映射到屏幕的长宽上,即可得到Screen Space。
【重要】rasterize_triangle实现
这个函数用于光栅化单个三角形,它在draw()
中被调用。尽管不同作业中的draw()
实现方式不一样,但都是通过某种方式传入了一堆三角形,进行一些预处理(作业3分析)后逐个调用rasterize_triangle()
进行光栅化。
它的实现大致步骤如下:
计算获取三角形的bouding box,遍历其中的每个像素,获取每个像素的中心坐标(+0.5),据此判断是否位于三角形内部。
若不在三角形内部,则跳过该像素;若在内部,则通过后续步骤光栅化。
通过重心坐标,插值计算该像素的z-buffer近似值。