此为个人学习笔记,总结内容来源于网络各个平台,如有错误欢迎指摘
光栅化成像
光栅化
本节内容附加资料:
在进入具体的直线光栅化以及三角形光栅化算法之前,我们首先需要知道光栅化是一个什么样的过程。
简单来说光栅化的目的就是将想要展现的物体给真正现实到屏幕上的过程,因为我们的物体其实都是一个个顶点数据来表示的,如何将这些蕴含几何信息的数据转化为屏幕上的像素点就是光栅化所考虑的东西。
比如说一条直线,究竟该用哪些像素点去逼近它,一个三角形,又用哪些像素集合表示它,这都是光栅化的过程。
在上一节中物体已经被映射到了屏幕上,那么屏幕是如何显示出来的呢
三角形的光栅化
为什么不是四边形五边形的光栅化,偏偏要谈三角形呢,因为三角形是最基本的多边形,大部分的模型都是用一个个三角形面表示,且任意的其它多边形其实都可以转化成多个三角形的形式,因此三角形的光栅化可以说是图形学中最基础的部分了。
那么三角形会在屏幕上被显示成什么呢
离散化三角形
做到这一步我们需要采样
请注意,采样的概念在接下来的文章中将会出现多次
概念化解释:取样也叫采样,是把连续的模拟量用一个个离散的点来表示。将时间轴上连续的信号每隔一定的时间间隔抽取出一个信号的幅度样本,使其成为时间上离散的脉冲序列。
如果我们将各个像素的中心点是否在三角形中作为唯一的判定标准,那么三角形将会被这样显示
对应的伪代码如下
经过这样的简单采样,三角形将会被这样显示出来
内与外
上面提到的采样是针对整个屏幕空间进行的,其中有一个函数inside(…)负责返回像素判定点是否在三角形内,我们该如何实现它呢
例如在这幅图中,该如何判断任意一点 Q Q Q是否在三角形 P 0 P 1 P 2 P_0P_1P_2 P0P1P2之内呢
利用叉乘的性质,如果满足
- P 0 P 1 × P 0 Q P_0P_1 × P_0Q P0P1×P0Q
- P 1 P 2 × P 1 Q P_1P_2 × P_1Q P1P2×P1Q
- P 2 P 0 × P 2 Q P_2P_0 × P_2Q P2P0×P2Q
以上三个多项式结果同号,则点 Q Q Q在三角形 P 0 P 1 P 2 P_0P_1P_2 P0P1P2内,否则不在
边界情况
如果空间中有多个三角形,而一个像素采样点恰巧在这两个三角形的边界处,那该如何判定呢
对于这种情况需要自己进行判断,并没有统一标准,我们需要知道图形学的宗旨:看起来真实就可以
优化方法
因此自然的,只需要遍历每一个点就可以得出三角形的光栅化结果了,当然我们还可以进一步的进行优化,因为显然并没有必要去测试屏幕中的每一个点,一个三角形面可能只占屏幕很小的部分,可以利用一个包围盒(bounding box)包围住想要测试的三角形,只对该包围盒内的点进行采样测试
除了包围盒外还有多种优化方法,例如在下面这幅图中,找到第一个判定成功的像素点后,直接进行后续同行的判定
不同的方法适用于不同的情况,请灵活使用
走样与反走样
如果我们就此结束,将上一节中采样得到的离散化三角形显示出来的话,会是这种情况
发生这种不真实的,我们不想要的现象被称为走样,走样产生的原因是采样不足,即采样量变化的过快,而采样速率跟不上,这里出现了多个名词,我们将会一一解释
走样 Aliasing
一张图片被放大会出现锯齿、拍摄电脑屏幕会出现奇怪的波纹、快速转动的车轮会看起来像是在倒转。这些现象都可以被称为“走样”,产生的原因即是“采样”不足。
以上提到的三种情况分别对应以下名词:锯齿、摩尔纹以及车轮效应。
其中锯齿与摩尔纹是屏幕在空间上的采样不足,而发生在现实中的倒转车轮效果则是大脑对光线的时间的采样不足。
采样 Sample
采样又名抽样,就像检测工厂中产品合格率使用的抽样调查一样
采样在信号处理上有更多的含义,在这里不过多叙述,仅仅介绍一些基础概念
上文已经提过,出现走样的原因是采样量变化的过快,而采样速率跟不上,如图所示
使用同样的采样速率,不同频率的函数采样后的效果不同,可以观察到频率高的失真度越大
在更极端一点的情况下,两种截然不同的信号在一种采样方式下,可能会得到完全相同的结果
滤波 Filtering
首先我们来看一下,一张图片经过傅里叶变换后从时域转变为频域的效果
右侧这张图像的含义是
- 中心点为低频信号,四周为高频信号
- 十字效果产生的原因是图像非四方连续,导致信号的突变
如果你还记得之前提到的内容的话,你就能知道这里的低频与高频的含义
- 低频信号指颜色变换程度较小的地方
- 高频信号值颜色变换程度较大的地方
那么如果可以对图像的信号进行处理以后,再使用逆傅里叶变换逆向回图像会产生什么效果呢
滤波:将信号中特定波段频率滤除的操作
当然还可以对特定频段的信号进行过滤,产生出介于两者之间的效果
卷积 Convolution
这里的卷积是图形学上简化版本的卷积
例如在如下的操作中
在Result中计算的结果,是Signal中的值经过Filter(卷积核/滤波器)对周围值按权取值平均计算出的结果
并依次往下进行,直至计算完Signal中的所有值
卷积有如下定理
在时域中的卷积,等于在频域中的乘积
在时域中的乘积,等于在频域中的卷积
由此在应用卷积时有以下两种路线
一:
- 在时域中使用卷积滤波
二:
- 使用傅里叶变换从时域转换为频域
- 点乘由傅里叶变换来的卷积核
- 使用逆傅里叶变换从频域转换为时域
下图解释了这一定理,两种路线可以得到相同的效果
滤波器 Filter
滤波器有许多种,在此仅仅介绍一种盒状滤波器(Box Filter)
将这个滤波器表示为图像如下
- 盒子的大小也有影响,更大的滤波器会使得图像丧失更多高频信号,即更加模糊
这一点可以这么思考
- 如果盒子超过了图像尺寸,那么将会只显示出一个颜色
- 如果盒子等于一个像素的大小,那么相当于没有做任何处理
时域 Spatial Domain与频域 Frequency Domain上的采样
图中左侧为时域,右侧为频域,对应关系为
- 左侧经过傅里叶变换可得到右侧
- 左侧区域的采样(a)乘上(c)冲激函数得到结果(e),与此对应的频域操作则为卷积,即右侧的操作过程
- 表现结果为(f)不断在冲激函数上重复的频谱
我们不需要过多理解这些内容,这里我们只要知道在频域上的采样效果:在冲激函数上不断重复的频谱
如图所示,上图是一种在频域的临界采样情况,若采样时采样间隔变大,则导致采样频率降低,不同的频谱会堆叠在一起,即图中的Aliasing,这里便发生了走样
反走样Antialiasing
走样发生的原因可总结为采样的速率慢于被采样对象的变化频率,在图形成像上的反走样由此可选择路线为
- 增大采样速率
- 增加屏幕分辨率
- 降低被采样对象变化频率
- 采样前过滤掉高频信号
前者不必多说,即像素点的增加会减小空间上的采样间隔,对于后者而言如图所示
非常值得注意的一点是:在采样前进行低通滤波处理,否则不会得到理想的效果
原因很好理解,因为对已经发生alias的频谱再使用低通滤波处理,得到的频谱仍有可能是存在alias的,所以模糊掉的只是已经存在的锯齿
对于Pre-Filter的选择多种多样,在此以一个像素大小的Box Filter为例
如图所示,在这一个像素大小的区域中,将根据图形覆盖的面积决定颜色的分布量,不再是最开始的采样结果中的01分布,而是具有过渡的状态
以MSAA为例,将每个像素点的采样点从中心变为N×N的形势,对覆盖点的数量进行颜色插值
虽然图示的采样点仅仅是被简单的划分出来,但在实际应用中会有多种划分方式
反走样,或者在这里更多指的是抗锯齿,的方法有许多种,更多的思路不在此一一解释
深度缓冲 Z-Buffer
目前为止我们所接触的都是单个图形的光栅化过程,当场景中存在多个物体该如何判断它们的前后关系呢
非常朴素的做法是根据物体的前后关系进行绘制顺序排列,即靠后的物体先绘制,靠前的物体后绘制,这种方法被称为画家算法
其中的问题不言而喻,对于上图这种部分遮挡又部分被遮挡的情况,该算法无法完成绘制
但我们可以把画家算法的思路从物体转移到像素,或者说是采样点上去,这就是Z-Buffer
- Z-Buffer算法需要为每个像素点维持一个深度数组记为zbuffer,其每个位置初始值置为无穷大(即离摄像机无穷远)。
- 随后我们遍历每个三角形面上的每一个像素点[x,y],如果该像素点的深度值z,小于zbuffer[x,y]中的值,则更新zbuffer[x,y]值为该点深度值z,并同时更新该像素点[x,y]的颜色为该三角形面上的该点的颜色。
在接下来的内容中,为了便于理解z将被一直取正值,即值越大意味着离我们的距离越远,虽然在之前z一直是一个负数
至此我们可以得到正确的遮挡关系了,在渲染时,会额外多一张反应z值的深度图
- frame buffer存储颜色信息
- depth buffer(z-buffer)存储深度信息
关于Z-Buffer算法的更多解释如下
这个算法的时间复杂度是 O ( n ) O(n) O(n),因为它仅仅是找出了深度值最小的三角面,并没有进行排序,我们无须担心这一点的计算量很大,你可以相信你的GPU受过特殊训练
作业内容
任务目标
在上次作业中,虽然我们在屏幕上画出一个线框三角形,但这看起来并不是那么的有趣。所以这一次我们继续推进一步——在屏幕上画出一个实心三角形,换言之,栅格化一个三角形。
上一次作业中,在视口变化之后,我们调用了函数rasterize_wireframe(const Triangle& t)
。但这一次,你需要自己填写并调用函数 rasterize_triangle(const Triangle& t)
。
该函数的内部工作流程如下:
-
创建三角形的 2 维 bounding box。
-
遍历此 bounding box 内的所有像素(使用其整数索引)。然后,使用像素中心的屏幕空间坐标来检查中心点是否在三角形内。
-
如果在内部,则将其位置处的插值深度值 (interpolated depth value) 与深度缓冲区 (depth buffer) 中的相应值进行比较。
-
如果当前点更靠近相机,请设置像素颜色并更新深度缓冲区 (depth buffer)。你需要修改的函数如下:
rasterize_triangle(...)
: 执行三角形栅格化算法static bool insideTriangle(...)
: 测试点是否在三角形内
-
你可以修改此函数的定义,这意味着,你可以按照自己的方式更新返回类型或函数参数。因为我们只知道三角形三个顶点处的深度值,所以对于三角形内部的像素,我们需要用插值的方法得到其深度值。我们已经为你处理好了这一部分,因为有关这方面的内容尚未在课程中涉及。插值的深度值被储存在变量 z_interpolated中。请注意我们是如何初始化 depth buffer 和注意 z values 的符号。为了方便同学们写代码,我们将 z 进行了反转,保证都是正数,并且越大表示离视点越远。在此次作业中,你无需处理旋转变换,只需为模型变换返回一个单位矩阵。最后,我们提供了两个 hard-coded 三角形来测试你的实现,如果程序实现正确,你将看到如下所示的输出图像:
需要注意的是该作业框架中没有补充透视投影变换矩阵函数内容和模型变换矩阵函数内容,这里仅仅需要补充前者
提高内容:
用 super-sampling 处理 Anti-aliasing :
你可能会注意到,当我们放大图像时,图像边缘会有锯齿感。我们可以用 super-sampling来解决这个问题,即对每个像素进行 2 * 2 采样,并比较前后的结果 (这里并不需要考虑像素与像素间的样本复用)。需要注意的点有,对于像素内的每一个样本都需要维护它自己的深度值,即每一个像素都需要维护一个 sample list。最后,如果你实现正确的话,你得到的三角形不应该有不正常的黑边。
代码解释
使用以下代码并不会得到与作业图完全相同的结果,因为这次的框架内容并没有让z保持正值
若想得到相同的结果,请在将摄像机以Z轴旋转180°
判断在三角形内部的采样点
static bool insideTriangle(int x, int y, const Vector3f* _v)
{
// TODO : Implement this function to check if the point (x, y) is inside the triangle represented by _v[0], _v[1], _v[2]
//测试点的坐标为(x, y)
//三角形三点的坐标分别为_v[0], _v[1], _v[2]
//叉乘公式为(x1, y1)X(x2, y2) = x1*y2 - y1*x2
//(1)准备三角形各边的的向量
Eigen::Vector2f side1;
side1 << _v[1].x() - _v[0].x(), _v[1].y() - _v[0].y();
Eigen::Vector2f side2;
side2 << _v[2].x() - _v[1].x(), _v[2].y() - _v[1].y();
Eigen::Vector2f side3;
side3 << _v[0].x() - _v[2].x(), _v[0].y() - _v[2].y();
//(2)准备测量点和三角形各点连线的向量
Eigen::Vector2f v1;
v1 << x - _v[0].x(), y - _v[0].y();
Eigen::Vector2f v2;
v2 << x - _v[1].x(), y - _v[1].y();
Eigen::Vector2f v3;
v3 << x - _v[2].x(), y - _v[2].y();
//(3)三角形各边的的向量叉乘测量点和三角形各点连线的向量
float z1 = side1.x() * v1.y() - side1.y() * v1.x();
float z2 = side2.x() * v2.y() - side2.y() * v2.x();
float z3 = side3.x() * v3.y() - side3.y() * v3.x();
//(4)判断叉乘结果是否有相同的符号
if ((z1 > 0 && z2 > 0 && z3 > 0) || (z1 < 0 && z2 < 0 && z3 < 0))
{
return true;
}
else
{
return false;
}
}
或者是另一种简化版本
static bool insideTriangle(int x, int y, const Vector3f* _v, int)
{
Vector2f point(x, y);
//A-0 B-1 C-2
//_v中存储的是三角形的三个顶点的三维坐标,使用.head取其x与y进行判断即可
Vector2f AB = _v[1].head(2) - _v[0].head(2);
Vector2f BC = _v[2].head(2) - _v[1].head(2);
Vector2f CA = _v[0].head(2) - _v[2].head(2);
Vector2f AP = point - _v[0].head(2);
Vector2f BP = point - _v[1].head(2);
Vector2f CP = point - _v[2].head(2);
return AB[0] * AP[1] - AB[1] * AP[0] > 0
&& BC[0] * BP[1] - BC[1] * BP[0] > 0
&& CA[0] * CP[1] - CA[1] * CP[0] > 0
||
AB[0] * AP[1] - AB[1] * AP[0] < 0
&& BC[0] * BP[1] - BC[1] * BP[0] < 0
&& CA[0] * CP[1] - CA[1] * CP[0] < 0;
}
三角形的光栅化
框架注释中给出的深度插值的部分,需要使用c++17标准
void rst::rasterizer::rasterize_triangle(const Triangle& t)
{
//执行三角形栅格化算法
auto v = t.toVector4();
//f12进入toVector4()处后可以发现返回值是array<Vector4f, 3>
//我的理解:v是一个包含三个顶点信息的数组,每个顶点坐标用Vector4f(齐次坐标)表示
// TODO : Find out the bounding box of current triangle.
// iterate through the pixel and find if the current pixel is inside the triangle
//(1)用矩形将三角形包围起来,找到矩形的四个顶点,构建三角形包围盒
float min_x = std::floor(std::min(v[0].x(), std::min(v[1].x(), v[2].x())));
float max_x = std::ceil(std::max(v[0].x(), std::max(v[1].x(), v[2].x())));
float min_y = std::floor(std::min(v[0].y(), std::min(v[1].y(), v[2].y())));
float max_y = std::ceil(std::max(v[0].y(), std::max(v[1].y(), v[2].y())));
//(2)遍历三角形包围盒中的所有测试点
for (int x = min_x; x <= max_x; x++)
{
for (int y = min_y; y <= max_y; y++)
{
// If so, use the following code to get the interpolated z value.
//auto[alpha, beta, gamma] = computeBarycentric2D(x, y, t.v);
//float w_reciprocal = 1.0/(alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w());
//float z_interpolated = alpha * v[0].z() / v[0].w() + beta * v[1].z() / v[1].w() + gamma * v[2].z() / v[2].w();
//z_interpolated *= w_reciprocal;
// TODO : set the current pixel (use the set_pixel function) to the color of the triangle (use getColor function) if it should be painted.
//判断是否在三角形内,如果在内部,则将其位置处的插值深度值 (interpolated depth value) 与深度缓冲区 (depth buffer) 中的相应值进行比较。
if (insideTriangle(x, y, t.v))
{
//以下是计算插值的内容,暂时看不懂,先抄别人博客补全
//最小深度,默认是无穷远
float min_depth = FLT_MAX;
//如果在三角形内部,计算当前深度,得到当前最小深度
auto tup = computeBarycentric2D(x + 0.5, y + 0.5, t.v);
float alpha, beta, gamma;
std::tie(alpha, beta, gamma) = tup;
float w_reciprocal = 1.0 / (alpha / v[0].w() + beta / v[1].w() + gamma / v[2].w());
float z_interpolated = alpha * v[0].z() / v[0].w() + beta * v[1].z() / v[1].w() + gamma * v[2].z() / v[2].w();
z_interpolated *= w_reciprocal;
min_depth = std::min(min_depth, z_interpolated);
//如果x,y所在点的深度小于z-buffer的深度,
if (depth_buf[get_index(x, y)] > min_depth)
{
//获得最上层应该渲染的颜色
Vector3f color = t.getColor();
Vector3f point;
point << x, y, min_depth;
//更新深度
depth_buf[get_index(x, y)] = min_depth;
//更新所在点的颜色
set_pixel(point, color);
}
}
}
}
}
提高部分
在上个函数(1)后加入以下部分
//MSAA 2*2
std::vector<Vector2f> multSample
{
{ 0.25, 0.75 },
{ 0.75, 0.75 },
{ 0.25, 0.25 },
{ 0.75, 0.25 }
};
int count = 0;
for (int i = 0; i < 4; i++)
{
if (insideTriangle(x + multSample[i].x(), y + multSample[i].y(), t.v))
{
count++;
}
}
并将原有的if (insideTriangle(x, y, t.v))
改为if (count)
//更新所在点的颜色
set_pixel(point, color);
}
}
}
}
}
提高部分
在上个函数(1)后加入以下部分
```C++
//MSAA 2*2
std::vector<Vector2f> multSample
{
{ 0.25, 0.75 },
{ 0.75, 0.75 },
{ 0.25, 0.25 },
{ 0.75, 0.25 }
};
int count = 0;
for (int i = 0; i < 4; i++)
{
if (insideTriangle(x + multSample[i].x(), y + multSample[i].y(), t.v))
{
count++;
}
}
并将原有的if (insideTriangle(x, y, t.v))
改为if (count)
将颜色信息从Vector3f color = t.getColor()
修改为Vector3f color = t.getColor() * (count / 4.0)