在记录笔记之前,先简单说一下我个人对"渲染(Rendering)"这个词的理解:
渲染就是计算显示设备上任意一个像素点在当前时间点应该显示什么颜色的过程。
从微观角度来看,最终的颜色由当前时间点所有到达该显示设备的光子(在游戏中则是游戏场景中到达虚拟相机的光子)决定。但是显然要追踪数量如此庞大的光子对算力的要求极高,因此从宏观角度拆解一下,我们可以认为这个颜色是由多种元素叠加起来的结果,包括但不限于:
- 一个物体本身的颜色
- 物体和观察点之间的半透明物体(比如雾)
- 光的影响,包括物体的自发光、环境光、反射光等
- 其他物体的投影和倒影
所有的技术,本质上都是在做两件事:
- 更精确的颜色计算
- 更低的算力消耗
Chapter 2: 图形渲染管线(The Graphics Rendering Pipeline)
本章简单介绍了实时渲染的核心工具 - 渲染管线的步骤。渲染管线的主要作用就是通过给定的虚拟相机、3D物体、光源和其他元素,生成一张2D图像。
简介
渲染管线大体可以分成四个阶段,它们分别是:
- 应用阶段(Application Stage):由应用(软件)端发起,使用CPU并行处理任务,如碰撞检测、加速度计算、动画、物理模拟等。也可以简单理解成计算几何形状在当前时间点应该处于哪个位置、其形状如何。
- 几何处理阶段(Geometry Processing Stage):主要处理变形、投影等几何形状相关的内容。通常由GPU来计算应该在哪里、如何绘制哪些几何形状。
- 光栅化阶段(Rasterization Stage):通过输入的三个顶点,将其组合成三角形,确定包含在该三角形中的所有像素,将其输出到下一阶段。完全由GPU处理。
- 像素处理阶段(Pixel Processing Stage):计算每一个像素的颜色。可能进行其他处理,如进行深度测试以决定是否需要显示颜色,或者将新计算的颜色和前一次计算的结果进行混合。完全由GPU处理。
应用阶段
应用阶段的任务是通过CPU执行的,可以理解为传统编程,或shader之外的编程。
这一阶段的优化可以降低后面阶段的算力压力,做法就是减少后面阶段需要渲染的三角面数量。举个最简单的例子:把山后面的树隐藏掉,后续阶段GPU就不需要去计算是否要显示这些树。
另外通过Compute Shader我们可以将大量可以并行的计算放到GPU中计算从而节省CPU资源。
应用阶段的任务运行依赖于CPU,不会被拆分成多个子阶段并行运行。但是,后面的章节会介绍多种方法利用CPU的超标量(Superscalar)架构在多个处理核心上并行运行任务以提高效率。
输出:需要渲染的几何形状的点、线、面数据 - 即渲染图元(rendering primitives)
几何处理阶段
这个阶段负责绝大多数针对单个顶点的操作,可以被划分为4个子阶段,分别是:
- 顶点着色(Vertex Shading)
- 投影(Projection)
- 裁剪(Clipping)
- 屏幕映射(Screen Mapping)
顶点着色
顶点着色的主要任务有两个,分别是:
- 计算顶点的位置,也可以做一些动画变形,后续章节会讲到
- 输出顶点相关的其他信息,如法线向量、贴图坐标等
对一个物体上色,传统方法是根据每个顶点的光线、坐标和法线向量来计算该顶点的颜色,然后通过顶点之间颜色的插值来绘制整个三角面的颜色 - 即顶点着色器(Vertex Shader)。
随着GPU算力的提高,对于物体的上色方法逐渐转向计算每个像素点而非顶点的颜色,顶点着色器的功能逐渐变成单一的输出顶点相关信息。
通常来说,物体的顶点数量会远小于像素数量,逐个计算顶点的颜色再进行插值的计算量会远小于逐个计算像素的颜色,但计算出的颜色的精确度也会远低于后者。
先说第一个任务,计算顶点位置。
一个顶点从三维世界被转换到屏幕的过程会经历好几次坐标系统变换的过程:
- 模型变换(model transform):最开始时,物体存在于自己的模型空间(model space),没有被变形过。同一个几何形状可以被施加不同的模型变换 - 位移、旋转和缩放。从根本上来说,在变换过程中只有顶点和它的法线向量会变化。变换后的空间被称为世界空间(world space)。
- 视图变换(view transform):只有相机或观察者能看到的物体才会被渲染。为了方便后续的投影和裁剪,物体和相机会被施加视图变换,作用是将相机置于坐标原点,并正对z轴负方向。变换后的空间被成为相机空间(camera space),或视图空间(view space)。
两种变换都用一个4x4的矩阵来表示,后续章节会详细说明。
再来说第二个任务,输出顶点相关的其他信息。
为了模拟一个真实的场景,仅仅渲染一个物体的形状和位置肯定是不够的,必须对它的外观做准确的描述。这个描述外观的过程被成为着色(shading),方法是通过一个着色方程(shading equation)来计算物体的材质(material)和光照对它产生的影响。方程需要参数,一个顶点可以包含着色方程需要的各种参数,如位置、法线向量、颜色或其他相关数值,这些参数就是所谓的材质。
输出:所有顶点的位置信息和通过着色方程计算出的其他信息(如颜色、各种向量、UV坐标等)
投影
在投影阶段,视图空间被转换到一个对角顶点坐标为(-1,-1,-1)到(1,1,1)的单位立方体(unit cube)内,这个立方体被成为规范视域体(canonical view volume)。
常见的投影方式有两种:
- 正交投影(orthographic projection):正交投影是平行投影(parallel projection)的一种,特征是两条平行线在投影之后仍然平行。在建筑领域,还有其他一些平行投影方式,如斜轴投影(oblique projection)和轴测投影(axonometric projection)。
- 透视投影(perspective projection):特征是离相机越远的物体在投影后大小越小;平行线在最远处会相交。
两种变换都用一个4x4的矩阵来表示,后续章节会详细说明。
在变换后,空间从视图空间变成了裁剪空间(clip space),模型的坐标变成了裁剪坐标(clip coordinates),其本质是一个齐次坐标(homogeneous coordinates)。
简单理解齐次坐标:在三维空间中x、y坐标相同而z坐标不同的点在投影到二维空间时会重叠。为了解决这个问题,我们使用N+1个量表示N维坐标,如一个三维坐标(x,y,z),引入一个量w,表示成(x,y,z,w),它在传统坐标系统(欧式坐标)中对应的点是(x/w, y/w, z/w),这样,我们就可以用w来代表这个点距离原点的远近。当w=0时,这个点在欧式坐标中为(∞,∞,∞),也即无穷远,从几何角度讲,它只具有方向意义,不代表一个具体的点。
输出:所有顶点的位置信息(用齐次坐标表示)和通过着色方程计算出的其他信息(如颜色、各种向量、UV坐标等)
可选顶点处理
下面几个阶段非必须,是否使用取决于硬件能力和开发者的需求。它们之间有先后顺序。
- 曲面细分(Tessellation):为简单的模型自动创造顶点,使模型细化,从而获得更好画面效果。
- 几何着色器(Geometry Shader):这项技术早于曲面细分出现,在大多数GPU上都有该功能。它和曲面细分类似,但更简单、更有局限性。最常用的场景是用来生成粒子,比如当我们要模拟一个烟花爆炸效果时,使用几个顶点,分别将其变成一个四边形,在着色后能更好的模拟烟花爆炸效果。
- 流输出(Stream Output):缓存当前的顶点信息,用于后续的其他计算和处理。这项技术的典型用法是模拟粒子,比如上述的烟花爆炸效果。
输出: 所有顶点的位置信息(用齐次坐标表示)和通过着色方程计算出的其他信息(如颜色、各种向量、UV坐标等)
裁剪
只有完全或部分处于视域体内的图元才会进入后续的光栅化阶段。一个图元可能包含三种情况:
- 完全位于视域体内部:不处理,直接进入下个阶段
- 完全位于视域体外部:不处理,直接舍弃
- 部分位于视域体内部:进行裁剪,在图元和视域体的交界处生成新的顶点,舍弃外部顶点。
这里会用到透视除法(perspective division),即将(x,y,z,w)转换成(x/w, y/w, z/w)。
输出:所有顶点的位置信息(三维的标准化设备坐标(Normalized Device Coordinate, NDC))和通过着色方程计算出的其他信息(如颜色、各种向量、UV坐标等)
屏幕映射
NDC为三维坐标,而屏幕是一个范围从(x1, y1)到(x2, y2)的二维空间,因此,需要将NDC的x、y坐标映射到屏幕空间,映射后的x、y坐标组成了屏幕坐标系(screen coordinates)。由于OpenGL和DirectX的z坐标范围不同(前者是[-1, 1],后者是[0, 1]),为了方便,我们也需要将z坐标映射到默认的[0, 1]范围内。屏幕坐标系和映射后的z坐标一起被成为窗口坐标系(window coordinates)。
输出:所有顶点的窗口坐标和通过着色方程计算出的其他信息(如颜色、各种向量、UV坐标等)
光栅化阶段
光栅化阶段的目的是找出上一阶段输入的所有图元范围内的像素点。它包含两个子阶段,分别是:
- 三角形设置(triangle setup),也称图元装配(primitive assembly)
- 三角形遍历(triangle traversal)
判断一个三角面是否覆盖某个像素取决于开发者如何设置GPU的渲染管线,比如使用点采样:最简单的方式是使用像素中间的单点采样,如果该点位于三角面内部,则认为该像素也位于三角面内部;也可以使用超级采样(supersampling)或多重采样抗锯齿技术(multisampling antialiasing techniques)。也可以使用保守的方案,只要一个像素有一部分被三角面覆盖就认为该像素位于三角面内部。
三角形设置
上一个阶段输入的信息只包含三角面三个顶点的相关信息。这个阶段会处理三角面的其他信息,如:
- 微分(differentials):用三个点绘制直线的算法
- 边界方程(edge equation):如果一个像素点同时覆盖两个三角面,如何避免它被重复渲染
三角形遍历
这一阶段会逐一检查一个像素的中心点或采样点是否被三角面覆盖,如果是,则为其生成一个***片元(fragment)。一个片元的属性值包括深度值和着色信息,是通过对三角面的三个顶点的数值插值计算得到的。
这一阶段也会进行透视矫正插值(Perspective-Correct Interpolation),即校正深度值在插值计算中的影响。如下图,在视图空间中的A、B、C三点投影到屏幕坐标系中分别是a、b、c三点,可以看到虽然c点在a、b点正中间,但C点并不在A、B两点这中间,因此需要使用深度值来进行校正。
输出:所有片元信息
像素处理阶段
本阶段负责逐一处理前面阶段输出的片元,分为两个子阶段:
- 像素着色(pixel shading)
- 像素合并(pixel merging)
像素着色
这个阶段GPU会通过开发者编写的程序计算片元的颜色,这个程序被成为像素着色器(pixel shader),在opengl中称为片元着色器(fragment shader)。贴图也是在这个阶段完成的。
输出:所有片元颜色
像素合并
所有像素的信息都被储存在颜色缓冲(color buffer)中。颜色缓冲是一个包含红、绿、蓝三个通道数值的二维数组。到了这一阶段,需要将得到的片元颜色和颜色缓存进行合并,因为多个片元可能对应一个像素位置(屏幕坐标相同但深度值不同),这个过程被称为光栅操作管线(raster operations (pipeline))或渲染输出单元(render output unit),简称ROP。
这个阶段也同时决定了物体是否可见。对于绝大多数硬件来说,这是通过z-buffer(也称depth buffer)算法来实现的。
z-buffer是一个和颜色缓冲形状大小一样的数组,它记录了当前像素渲染(可见)的片元对应的深度值。当要渲染一个新的片元时,我们需要拿它的深度值和它对应像素记录的z-buffer值进行比较。如果前者较小,说明它离相机比较近,那就需要用新片元的颜色和深度值分别覆盖颜色缓冲和z-buffer中对应位置的值;反之,则说明新的片元离相机比较远,会被其他物体遮挡,因此不会被渲染。
由于z-buffer只有渲染、不渲染这两种结果,因此它无法用于半透明物体。半透明物体只能在非透明物体渲染完成后,按从后到前的顺序进行渲染;或使用其他算法(后续章节会讲到)。
还有一个概念叫做模板缓冲(stencil buffer),和颜色缓冲类似,它也是一个数组,每个像素对应一个0-255的值(8位)。stencil这个词的概念来源于印刷工业中使用的版面模子,在图形渲染中,可以只让特定模板值的像素参与渲染。
系统中所有的缓冲组成了framebuffer。
当渲染管线进行到光栅化阶段时,屏幕显示的是颜色缓冲中的值。为了避免人眼看到光栅阶段的过程,我们会使用双重缓冲(double buffering):场景在后台缓冲(back buffer)中进行离屏渲染,在完成后,后台缓冲的内容会在垂直回扫(vertical retrace)时替换前台缓冲(front buffer)中的内容。
本章总结
上述渲染管线是多年来实时渲染领域进化后的结果,但并非唯一的渲染管线,离线渲染管线的进化方向就和实时渲染完全不同。电影领域经常使用微多边形管线(micropolygon pipelines),但近年来正在被光线追踪(ray tracing)和路径追踪(path tracing)所替代。
在过去的很多年间,开发者仅能通过图形API提供的固定管线(fixed-function pipeline)来处理渲染流程,无法自行编程,比如2006年发布的任天堂Wii。在此之后,开发者开始能够使用可编程GPU来自定义渲染管线。