1、回顾
图形渲染管线:从模型空间到屏幕空间
图形渲染管线的核心是将三维模型的顶点坐标逐步转换为屏幕坐标,主要包括以下五个阶段。
1. 模型变换(Model Transform)
模型最初位于模型空间(Object Space),顶点坐标描述模型自身的局部位置。通过模型矩阵 M_model,对模型进行平移、旋转和缩放,将顶点转换到世界空间(World Space)。
变换公式:
P_world = M_model * P_object
2. 视图变换(View Transform)
**视图变换负责将世界空间转换为相机空间(Camera Space)。**相机通常由三个向量定义:位置 e、视线方向 g 和参考上方向 t。
根据这些向量构造视图矩阵 M_view,将相机位置移动到原点,并使相机视线对齐负 Z 轴、上方向对齐正 Y 轴。
视图矩阵:
M_view = R_view * T_view
其中,T_view 负责平移,R_view 负责旋转。
变换公式:
P_camera = M_view * P_world
3. 投影变换(Projection Transform)
通过投影矩阵 M_proj,将相机空间中的顶点转换到裁剪空间(Clip Space)。
变换公式:
P_clip = M_proj * P_camera
投影方式主要分为两种:
-
透视投影(Perspective Projection):具有近大远小的效果,适合模拟真实相机。
-
正交投影(Orthographic Projection):不会产生透视缩放,常用于 CAD、工程制图和工业仿真。
投影完成后,GPU 会根据裁剪规则剔除完全位于可视区域外的图元,并对跨越边界的图元进行裁剪。
4. 齐次除法(Perspective Divide)
裁剪完成后,需要将裁剪空间中的四维齐次坐标转换为标准化设备坐标(NDC)。
假设裁剪空间坐标为 x_clip、y_clip、z_clip 和 w_clip,则计算公式为:
x_ndc = x_clip / w_clip
y_ndc = y_clip / w_clip
z_ndc = z_clip / w_clip
透视投影通过 w_clip 编码深度信息,从而产生近大远小的效果。标准正交投影通常满足 w_clip = 1,因此不会产生透视缩放。
需要注意,NDC 的深度范围取决于图形 API:OpenGL 的传统默认约定为 -1, 1,Direct3D 和 Vulkan 的常见约定为 0, 1。
5. 视口变换(Viewport Transform)
视口变换负责将 NDC 坐标映射到屏幕像素坐标,也就是要将-1,1里面的坐标转到屏幕为W*H的范围内。
假设视口左下角坐标为 x0、y0,宽度为 W,高度为 H,并采用左下角为原点的坐标约定,则:
视口变换矩阵(Viewport Transform Matrix):
M_viewport = | W/2 0 0 W/2 | | 0 H/2 0 H/2 | | 0 0 0 0 | | 0 0 0 1 |
2、光栅化
光栅化的流程可以分为以下的步骤:
1.图元装配
将顶点组装成三角形
↓
2. 视口变换
将顶点转换到屏幕坐标
↓
3. 计算包围盒
确定三角形覆盖的矩形范围
↓
4. 覆盖测试(光栅化采样)
检查像素内的采样点是否被三角形覆盖
↓
5. 属性插值
计算采样位置对应的深度、纹理坐标等
↓
6. 片元着色
根据纹理坐标进行纹理采样,计算颜色
↓
7. 深度测试与混合
决定最终的绘制结果
这里我们主要来关心第3和第4步,也就是包围盒和采样的步骤。
1、包围盒(AABB)

在光栅化阶段,我们拿到屏幕空间三角形的3个顶点坐标。
轴对齐包围盒(AABB),就是紧紧包裹住三角形,四条边平行于屏幕X轴、Y轴的矩形。
如果没有包围盒,光栅化代码需要遍历屏幕上全部像素,逐个判断像素中心点是否在三角形内部。屏幕像素数量巨大,绝大多数像素和三角形毫无关系,白白消耗算力。
包围盒的计算
假设三角形的三个顶点在屏幕空间中的坐标分别为:
P0 = (x0, y0)
P1 = (x1, y1)
P2 = (x2, y2)
计算包围盒的思路很简单:分别找出三个顶点中最小和最大的 X、Y 坐标。
第一步:计算 X 方向的边界
xmin = min(x0, x1, x2)
xmax = max(x0, x1, x2)
第二步:计算 Y 方向的边界
ymin = min(y0, y1, y2)
ymax = max(y0, y1, y2)
第三步:确定包围盒
得到以上四个边界后,就可以确定一个矩形:
左上角(或一个角点) = (xmin, ymin)
右下角(或对角点) = (xmax, ymax)
这里的"左上角"和"右下角"取决于屏幕坐标系的 Y 轴方向;更准确地说,这两个点是矩形的一对对角点。
这个矩形就是三角形的轴对齐包围盒(AABB)。
举个例子:
假设三个顶点分别是:
P0 = (2, 3)
P1 = (8, 5)
P2 = (4, 9)
那么:
xmin = min(2, 8, 4) = 2
xmax = max(2, 8, 4) = 8
ymin = min(3, 5, 9) = 3
ymax = max(3, 5, 9) = 9
因此,包围盒的两个对角点为:
(2, 3) 和 (8, 9)
得到这个矩形后,光栅化时就可以先遍历包围盒覆盖的像素区域,再判断哪些像素或采样点真正位于三角形内部。
这样就不需要检查屏幕上的每一个像素,可以减少不必要的计算。
2、采样
在确定好包围盒之后,对于包围和里面的每一个像素点,遍历之后,可以采样叉乘的方式来确定点是否处于三角形的内部。
假设三角形的三个顶点为 A、B、C,需要判断点 P 是否位于三角形内部。
首先,分别计算三条边与点 P 构成的向量,然后进行叉乘。
对于二维向量 U 和 V,它们的叉乘可以写成:
cross(U, V) = U.x * V.y - U.y * V.x
这个结果可以理解为带有正负号的面积。
-
结果大于 0:向量 V 位于向量 U 的逆时针一侧。
-
结果小于 0:向量 V 位于向量 U 的顺时针一侧。
-
结果等于 0:两个向量共线。
计算点 P 相对于三条边的位置
分别计算:
s1 = cross(B - A, P - A)
s2 = cross(C - B, P - B)
s3 = cross(A - C, P - C)
这三个结果分别表示点 P 相对于边 AB、BC、CA 的位置。
如果三角形的三个顶点按照逆时针顺序排列,那么点 P 位于三角形内部或边界上时,三个结果都应该大于或等于 0。
也就是:
s1 >= 0
s2 >= 0
s3 >= 0
如果三个条件全部满足,说明 P 位于三角形内部或边界上;只要有一个条件不满足,P 就位于三角形外部。
如果顶点按照顺时针顺序排列,判断符号则相反:三个结果都小于或等于 0 时,点位于三角形内部或边界上。
1、进一步理解采样
对于采样,我们可以从频率来理解采样点。

图片中的每一个点都可以看作是应该像素中心,当f(1)x变化到f(5)x,随着图片的刷新频率加快,原本的采样点也就不能表示出原来的频率变化。
采样得到的信息不足以正确表示原始信号,导致最终呈现的图像与原始图像不一致,这个也就叫做走样。
2、走样在图形渲染中的表现
在实时渲染中,走样通常表现为以下几种现象:
-
锯齿:斜线和三角形边缘呈现阶梯状。
-
摩尔纹:密集的纹理出现原本不存在的波纹(后续章节讲解纹理会提到)。
-
细节丢失:远处的细线或纹理逐渐消失(后续章节讲解纹理会提到)。
这些现象看起来不同,但都与有限采样无法准确表示原始图像有关。
3、抗锯齿
上面走样发生的原因都可以归结于采样点不足导致的无法有效的图案细节。
以下的的红色图案应该为三角形,但是由于采样点的不足使得边缘出现了锯齿的情况。

1、MSAA:通过增加覆盖采样,改善几何边缘
MSAA(Multisample Anti-Aliasing,多重采样抗锯齿)是一种常见的实时抗锯齿技术。
它的核心思路是:对于一个像素,不再只通过一个位置判断几何覆盖情况,而是在像素内部设置多个采样位置。
例如,4× MSAA 可以在每个像素中使用 4 个覆盖采样点。
假设某个像素的 4 个采样点中:
-
2 个位于三角形内部。
-
2 个位于三角形外部。
那么,这个像素的几何覆盖率就可以近似为 50%。
相比只用一个采样点判断内部或外部,多个采样点能够更准确地估计三角形覆盖了多少面积。
最终,像素边缘可以根据覆盖情况与颜色混合,呈现出更平滑的过渡。

也就是说先让要绘制的三角形模糊化,然后确定采样点,将原本位于三角形内部的采样点由一个变为4个,位于三角形边缘的像素框颜色根据采样点位于三角形的内部来确定颜色,实现更好的颜色过渡。
2. FXAA:在最终图像上寻找并平滑锯齿
FXAA 可以理解成一种"事后修图"的方法。
游戏先把画面正常渲染出来,然后 FXAA 再检查这张图,看看哪些地方可能有锯齿。
它主要看相邻像素的颜色变化。如果发现某条边缘的颜色变化特别明显,就会判断这里可能有锯齿,然后把边缘附近的颜色稍微混合一下,让边缘看起来更平滑。
可以简单理解为:
-
MSAA: 画图的时候,就尽量把物体边缘画准确。
-
FXAA: 图已经画好了,再把看起来不平滑的地方修一修。
FXAA 的好处是速度快,开销比较小,不需要额外进行那么多几何覆盖采样。
但它也有一个问题:它并不知道真实的物体边缘到底长什么样,只能根据已经画出来的图像猜。
所以,有时候它确实能把锯齿磨平,但也可能把一些本来就很细的线条、纹理一起弄模糊了。
从频率的角度来看,可以把锯齿理解成图像中不自然的高频变化。FXAA 会把其中一部分变化平滑掉,让画面看起来没那么扎眼。
不过,它并不能把之前采样时已经丢失的信息重新找回来。说白了,FXAA 是让锯齿不那么明显,而不是重新还原出真实的边缘。
3. TAA:利用连续帧的信息减少走样
TAA 的思路是:这一帧看不清楚的地方,可以参考前几帧。
假设你正在玩游戏,摄像机缓慢移动。同一个物体在不同帧里,位置会有细微变化。
如果每一帧都从完全相同的位置采样,那么一些细小的边缘可能一直采不准。
TAA 会故意让每一帧的采样位置稍微偏一点。这样,连续几帧采到的信息就不完全一样了。
然后,它会把上一帧的画面拿过来,尽量对准当前帧中的物体,再把历史画面和当前画面融合起来。
大致可以理解成:
-
当前帧先正常渲染,但采样位置稍微偏一点。
-
找到上一帧对应的画面。
-
根据物体的运动情况,把上一帧的画面尽量对齐。
-
把当前帧和历史画面结合起来,得到更平滑的结果。
这就像你连续拍了好几张照片,每张照片的位置都稍微有点不同。把这些照片的信息合理地结合起来,就可能比只看一张照片得到更多细节。
当然,TAA 不是简单地把几张画面直接叠在一起。因为物体会移动,也可能被其他物体挡住,所以系统必须判断:上一帧的这个像素,到底还能不能拿来参考。
如果判断不准,就容易出问题。
比如,快速移动的物体后面可能出现拖影;细小的线条可能变得模糊;物体刚从遮挡后面出现时,也可能残留上一帧的颜色。
所以,TAA 的效果不仅取决于怎么采样,还取决于能不能把历史画面对齐,以及该保留多少历史信息。
从频率的角度来看,TAA 就是利用连续帧提供的额外信息,弥补单帧采样不够充分的问题。
4、深度测试(z-buffer)
该博客里面现在还没有提及到纹理的相关内容,所以对于第5步的属性插值和第6步的片元着色这边先没有提到,在后续的章节中补充纹理知识后会完整的叙述一遍。
到目前,我们已经可以把一个获取到了三角形内的所有像素,通过设置三角形的RGB值,并可以通过抗锯齿的方式将三角形的边界情况完整的表达出来,那么这个时候,对于一个三维场景来说,物体是会有遮挡的,从摄像机的视角来看,近处的物体应该遮挡远处的物体。如果直接按照物体的提交顺序绘制,就可能出现远处的物体覆盖近处物体的情况。
为了解决这个问题,计算机图形学引入了 Z-Buffer(深度缓存)算法。
简单来说:
-
颜色缓冲区(Color Buffer)记录每个像素最终显示的颜色。
-
深度缓冲区(Depth Buffer)记录每个像素当前对应的最近深度。
-
当新片元比当前记录的片元更靠近摄像机时,就更新对应的颜色和深度
这里的片元可以先理解为像素的意思。
1、z-buffer工作流程
在开始绘制一帧之前,通常需要清空颜色缓冲区,并将深度缓冲区初始化为最远的深度值。
对于普通的深度测试模式,可以初始化为:
Z(x,y)=1.0
如果深度值越小越近,那么初始值 1.0 就相当于认为当前像素还没有记录任何更近的表面。
Z-Buffer 的整体流程并不复杂。
首先,将三维模型经过模型变换、观察变换和投影变换,转换到裁剪空间。经过裁剪、透视除法和视口变换后,得到屏幕空间中的几何图元。
接着,光栅化器将三角形转换为覆盖的片元,并为每个片元计算相应的深度值,深度缓冲区会逐渐记录各个像素上最近的可见表面。
对于每一个待处理片元,执行以下步骤:
-
获取片元对应的屏幕坐标 ((x,y))。
-
计算该片元的深度值 (z)。
-
读取深度缓冲区中该像素已有的深度值 (Z(x,y))。
-
比较新旧深度值。
-
如果新片元更近,则更新深度缓冲区和颜色缓冲区。
-
如果新片元更远,则丢弃该片元。
这样子每一个像素就设置为当前帧所表示的颜色,在下一帧开始前,清空两个缓冲区,循环的执行一样的操作。