什么是渲染管线
如果你之前接触过图形学或者接触过WebGL,你一定多少了解过GPU的渲染管线。在图形学API(如OpenGL,WebGL等)中,我们的定义任何事物都是在一个3D空间中的顶点坐标,而我们的显示器屏幕却是一个2D的空间数组。而从3D空间转换为2D空间的过程就是由图形渲染管线(Graphics Pipeline)去管理操作的。你也可以把他理解为:是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程。
基本我们也可以把图形渲染管线看为两部分:
1,将3D空间坐标转换和投影为2D坐标。
2,将2D坐标转换为实际有颜色的像素输出到屏幕上面。
渲染管线的工作流
我们可以把渲染管线想像成工厂中的流水线作业,它有被分为很多个阶段,每个步骤都会把前一个步骤的输出作为当前步骤的输入。所以说每一个步骤都是高度定制化和专一化的。每个阶段也都有自己独立的程序去执行。而且部分步骤是可以自定义执行程序,这些可自定义的步骤我们称它们为:顶点着色器,片元着色器和几何着色器(在webgl是不能自定义几何着色器的,暂时不去介绍)。在OpenGL和WebGL的着色器语言都是使用GLSL去书写和实现的。
也正是因为管线的每个步骤都是专一化和定制化,所以很容易并行执行。正是由于它们具有并行执行的特性,业界显卡都有成千上万的小处理核心,它们在GPU上为每一个阶段运行各自的程序,从而在渲染管线中快速处理传入的数据。
图2: 绿色的是计算单元,橙红色的是存储单元,橙黄色的是控制单元
下图中,对管线的每一个过程抽象为以下几个基本的步骤。蓝色部分为可自定义着色器的部分。
图3:渲染管线基本流程
如果看不懂渲染管线的流程也没关系,我们下面使用WebGL来一步一步来实现一个简单的几何体,来深入的了解一下渲染管线的流程。
从一个三角形开始
初始化上下文
webGL 是一种实现在浏览器端的3D绘图标准,WebGL基于OpenGL的标准封装的一套Javascript 的图形API。这样Web开发人员就可以借助系统显卡来在浏览器里更流畅地展示3D场景和模型了。所以开发者可以浏览器中通过canvas标签获取WebGL上下文,我们把webgl上下文信息存储在变量glContext中
const canvas = document.querySelector('canvas');
const glContext = canvas.getContext('webgl'); // 初始化的webgl 1.0标准上下文,当然还会有webgl2.0
设置顶点数据
在开始执行管线前,我们需要设置顶点数据,用来为后面的顶点着色器等阶段提供处理的数据。而且顶点数据是渲染管线的数据主要来源。顶点数据不只包括顶点的坐标,还有每个顶点的颜色,法线向量,纹理坐标等顶点属性信息。
webgl中仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它,我们可以叫他标准化设备坐标(Normalized Device Coordinates, NDC),任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。
const vertexs = [
// positions
-0.5, -0.5, 0.0,
0.5, -0.5, 0.0,
0.0, 0.5, 0.0,
];
从下图看到,标准化坐标的Y轴是屏幕的上方,X轴指向屏幕的右侧。
设置完成顶点坐标数据,需要将数据传递到GPU内存中(可以理解成显存),给到管线中的顶点着色器去使用。
- 初始化一个GPU内存空间给顶点用来存储大批的顶点数据
const vertexBuffer = glContext.createBuffer();
- 将申请到的空间绑定到类型为GL_ARRAY_BUFFER的顶点缓冲对象中上,再将顶点数据放在对应的内存中,顶点着色器通过顶点缓存对象就可以获取相关的顶点数据。
// 将GPU内存空间绑定到顶点缓冲对象
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// 在将顶点数据传到对应的GPU内存空间中
gl.bufferData(gl.ARRAY_BUFFER, vertexs, gl.STATIC_DRAW);
顶点着色器
顶点着色器用来处理输入的顶点数据。我们使用GLSL语言去编写顶点着色器,如下
attribute vec3 position;
void main() {
gl_Position = vec4(position, 1.0);
}
上面是一个最简单的顶点着色器,我们将其中position变量设置为一个三维向量(vec3), 他就是对应的顶点坐标。在顶点着色器中我们可以对顶点坐标做相应变换。比如我们可以操作把所有顶点像右移动一段距离。
因为我们传入了三个顶点坐标数据到顶点着色器中,所以这个着色器会分别执行三次,因为他们操作都是高度一致的,而且根据显卡多核特点,这三次操作时并行的。
attribute vec3 position;
void main() {
gl_Position = vec4((position + vec3(0.1, 0.0, 0.0)), 1.0);
}
计算出来最终的坐标位置把它赋值给gl_Position,传递给管线的下一阶段。
图元组装
图元组装是将顶点着色器中输出的顶点组装成指定的图元,在webGL中常见的图元主要包括以下7种类型:
所以我们在开发过,我们选择图元gl.TRIANGLES,最终将三个顶点组装为一个三角形图元。我们定义一个变量用来存图元
const mode = glContext.TRIANGLES;
在组装完图元以后,会针对图元做裁剪和背面剔出操作,从而减少发送到下一个阶段的图元个数。当时图元不在视锥范围(我们也叫它裁剪空间,每个顶点坐标的空间范围是附值给gl_Position的第四个值w,所以本例子中的裁剪范围为-1.0到1.0的范围)内的时候,会对其进行裁剪,如下图,红色三角直接被删除,黄色三角被裁剪掉一部分,并重新创建了两个新的定点。
背面剔除指的是剔除那些背对摄像机或观察者的图元。尝试想一个立方体,我们不论从那个角度看,最多只展示出来三个面,如果把其余几个看不到的面剔除掉,直接就删除我们看不到的图元,减少了传入管线下一个阶段的数据,从而提高管线的执行性能。
每个三角形图元包含三个点,我们利用三角形顶点的环绕顺序(Winding Order)来确定的正面(front-face)和背面(back-face),默认情况下,将逆时针的面会认为是正面。如下图远离观察者的面将会被剔除。
但是默认情况下,面剔除是关闭的,我们可以在初始化上下文后,去开启面剔除的能力。同时我们也可以设置顺时针为正面。
// 开启面剔除能力
glContext.enableglContext.CULL_FACE);
// 开启面剔除后设置顺时针为正面
glContext.frontFace(glContext.CW); // glContext.CCW为逆时针
// 关闭面剔除 glContext.disable(gl.CULL_FACE);
映射屏幕
前面我们更多的是对顶点坐标做相关的转换和操作,针对每一个顶点坐标在管线流程中会执行透视除法(gl_position中的xyz三个值分别除以w),本例中三角形的w值为1.0,所以xyz值不变。
执行完成透视除法后,相关的坐标会变成标准化设备坐标(Normalized Device Coordinates, NDC),范围全部在-1 <= xyz <= 1之间,任何不在这个空间内的点都会被丢弃。
经过上面的操作,得到了NDC坐标,我们将NDC转换成屏幕或窗口坐标。我们将这个过程叫视口变换(viewport Transform)。
NDC映射到窗口坐标需要经过平移和缩放两个变换,其中width、height、farVal和nearVal分别表示视口的宽高和远近。最终通过坐标变换变化后,视口坐标变为如下:
webgl提供了gl.viewport 去设置相关的变换参数 webgl提供了gl.viewport 去设置相关的变换参数
glContext.viewport(0, 0, 800, 800);
- 第一和第二参数代表的视口的左下角的x和y的坐标(0, 0),也就是在屏幕的左下角(屏幕的坐标是从左下角开始的),也就是上面公式的X和Y的值
- 第三和第四个参数代表的视口的宽和高,也就是上面公式的width和height
- 公式中的nearVal和FarVal,因为没有涉及到投影变换的一些知识,暂时不去讲
通过以上变化,我们得到了每个图元的转换到屏幕的坐标,后续将其处理过的数据和信息传递到渲染管线的下一步去做处理了。
光栅化
光栅化主要是将变换到屏幕空间的图元离散化为片元的过程。在屏幕的像素都是离散化的一个一个小块,而光栅化上一步传过来的图元基本都是线性的,所以在这一步我们需要将图元映射成一个个像素组成的片元。
在光栅化过程中主要做了一下几个事情:
- 管线在光栅化过程中使用了一些光栅化的算法,选择出在图元边界所包围的像素点,最终组成片元。常用的光栅化算法如Bresenham光栅化算法等。
- 如果在顶点着色器中的顶点中设置例如颜色,法线等属性,在光栅化过程对基于顶点对各个像素点做线性插值。
抗锯齿
你可能遇到模型边缘有类似于下图的锯齿的情况。这些锯齿边缘(Jagged Edges)就是光栅化将顶点数据(图元)转化为片段产生的,因为在光栅化算法是选取图元覆盖或者包围的像素点,而像素点都是一块一块的,所以最终呈现的效果会出现相关的锯齿。
接下来主要介绍超级采样和多重采样抗锯齿两种技术。
超级采样抗锯齿(SSAA):使用一个比分辨率更高的分辨率来渲染场景,当图像输出过程中,分辨率会被下采样(Downsample)至正常的分辨率,SSAA可以得到非常好的抗锯齿效果,不过SSAA需要的更大的计算量,引文这样比平时要绘制更多的片段,它也会带来很大的性能开销。
多重采样抗锯齿(MSAA):在前面的光栅化阶段,我们知道每个片段都有一个采样点,决定一个片段是否被三角面覆盖的方法就是看是否覆盖了该采样点,每个片段,我们使用多个采样点来决定片段是否被覆盖。
以下图第二个图为例,我们发现三角形覆盖了4个采样点中的两个,所以最终片段得到的颜色值需要乘以采样点覆盖率0.5,得到的是浅红色的颜色
片段着色器
片段着色器用来处理从光栅化后传递过来的像素片段,我们使用GLSL语言去编写片段着色器,如下
#ifdef GL_ES
precision mediump float;
#endif
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
从光栅化后到片段着色器是很多个片段,每一个片段都是一个像素集,在片段着色器中,我们会对批量并行处理每一个像素,例如GLSL代码中,我们给每一个像素颜色设置为红色。最终得到如下一个三角形。
从光栅化后到片段着色器是很多个片段,每一个片段都是一个像素集,在片段着色器中,我们会对批量并行处理每一个像素,例如GLSL代码中,我们给每一个像素颜色设置为红色。最终得到如下一个三角形。
还记得在视口变换中,我们生成的窗口坐标么,在片段着色器代码中,提供了一个变量gl_FragCoord可以拿到窗口坐标的x和y的值。我们更改一下代码
#ifdef GL_ES
precision mediump float;
#endif
void main() {
if (gl_FragCoord.x > 400.0) {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
} else if(gl_FragCoord.x < 400.0) {
gl_FragColor = vec4(0.0, 1.0, 0.0, 1.0);
}
}
最终我们得到了如下的三角形,因为视口坐标是以左下角为原点,并且设置了viewport的宽和高为800,所以在小于400时为绿色,大于400为红色。
同时,gl_FragColor还有一个Z分量,这个值就是深度值,后续会直接传到渲染管线下一步,去做深度测试。
同时GLSL还提供了一个可修改的变量值gl_FragDepth,我们可以修改他的值,从而修改像素的深度值,继续传给下一步做深度测试,这里不在多说。
类似图形学中的光照,阴影等相关的实现 大部分也是在片段着色器中实现的,至此我们继续向下看。
混合和测试
至此,我们屏幕上面还没有把我们起初定义的三角形渲染出来,渲染管线需要针片段着色器传过来的片段做测试,测试失败的像素直接删除,测试成功的点去保留。最终渲染到屏幕上。
渲染管线常见的测试主要包括模版测试和深度测试,管线先执行模版测试,但是默认情况下模版测试是关闭的,我们可以通过下面代码开启模版测试。如果想更深入模版测试如何工作,可以看文章
glContext.enable(glContext.STENCIL_TEST)
通过模版测试的片段会继续交给深度测试做相关的测试。
我们都知道前面的物体会遮挡住后面的物体,所以我们可以通过深度缓冲来实现这样的效果。通过深度测试的的片段会保留下来。深度测试的过程还是很简单:还记得上一个阶段的gl_FragColor还有一个Z分量,这个变量就是每个片段的深度值。深度测试去比较每个片段的深度值,默认情况下深度测试是小于深度值通过测试,所以片段值小于深度缓存就测试通过。如果大于就抛弃。我们也是可以修改深度测试规则的,如下代码:
gl.enable(gl.DEPTH_TEST);
gl.DepthFunc(gl.GREATER); // 深度值大于深度模版时通过测试
更多关于深度测试的知识可以看文章
通过测试的片段最终会渲染到屏幕中,我们可以看到最终的效果。
总结
至此,渲染管线的流程基本介绍完了,大家可以尝试自己去梳理一下这个流程,方便更深的记忆。在文章中很多细节没有介绍清楚,其中测试和混合相关的知识没有详细介绍,因为我感觉这些东西还是需要更详细的说明,所以后续会继续给详细去介绍。
这篇文章的目的主要介绍了渲染管线的流程,还有顶点数据传入到最终渲染到屏幕的流程,他们是怎么做传递和处理的,希望对你有帮助。