这篇文章,比较深入的讲解了渲染管线的大致过程,以及每个过程具体都干了什么事。文章大致分为三部分:
- 概括性的讲述渲染管线包含哪些部分,以及每部分大致干了些啥。
- 通过一个简单的例子,一步一步拆解讲述渲染管线的具体执行过程。
- 详细讲解渲染管线中的一些细节,如图元裁剪、屏幕映射、透视除、光栅化、左右手坐标系的转换等等。
1. 两句话描述渲染管线
将数据渲染到屏幕的过程中,GPU所执行的一系列有先后次序的处理逻辑(程序)。这些有先后次序的处理逻辑共同构成我们所说的渲染管线(程序) 或 渲染流水线(程序)。
2. 大致过程
如你所见,渲染管线包括很多部分,每一部分的处理结果都会作为下一部分的输入。两个蓝色部分分别是渲染管线的输入和输出。灰色的部分是底层逻辑,不能自定义。绿色部分支持自定义。
概括性的解释一下,渲染管线各个部分的作用:
- 0、顶点缓存: 在渲染一个模型前,需要将模型所有数据都存入缓存中。包括,坐标信息,颜色信息等。
- 1、顶点着色器 :从顶点缓存逐顶点读取数据进行处理。具体怎么处理,可以使用着色器语言,编写顶点着色器程序来自定义。如顶点的平移、缩放、旋转、坐标系转换都可以在这里处理,下一节在讲坐标变换的时候会详细说。
OpenGL使用的着色器语言是GLSL,WebGL是OpenGL派生出来的,WebGL的着色器语言也是GLSL(OpenGL Shading Language)。
- 2、图元/图形 装配: 图元分为三种,点、线、三角面,最终渲染到屏幕上的所有模型元素都是由这三种最基本的图元构成的。图元装配就是根据顶点着色器输出的数据、指定的装配方式和图元类型去装配图元。点图元只有一种装配方式,线和面图元分别有三种装配方式,下面会详细讲到。
图元装配的简单过程,如下图:传入三个顶点,设置的图元类型是三角形。如你所见,三个孤立的顶点被装配成了一个三角形。
- 3、光栅化: 光栅化就是将图元转换为像素点 或 片元的过程。如下,三角图元光栅化的过程。右图中每一个小方格都代表着一个片元。片元代表最终要显示到屏幕上的一个像素,存储着对应像素点的一些信息。这些信息包括:屏幕上WebGL-2维绘制区域的二维坐标、WebGL可视化空间范围内的三维坐标、以及 所有varying变量插值(文章下面讲光栅化细节时会详细讲到varying变量 )。
-
4、片元着色器: 给光栅化阶段的结果片元(像素点),逐片元上色。具体上什么颜色,可以使用着色器语言,编写片元着色器程序来自由控制。
-
5、深度/透明度测试 与 颜色混合: 当两个片元(像素)位置重叠时,就需要根据它们的z轴信息来确定那个片元在前那个在后,以此来决定最终显示那个。当前面的片元是半透明时,还需要计算出它俩融合后的颜色。这一阶段最终的处理结果会存入颜色缓冲区中,等待显示器读取。
-
6、颜色缓冲区: 颜色缓冲区存储最终要绘制到屏幕上的帧画面,由"(深度、透明度)测试与颜色混合"阶段输出的像素组成。当 当前帧所要绘制的物体全部经渲染管线处理 并 输入到了颜色缓冲区之后,就会通知浏览器,浏览器会调用底层api将这帧图像显示到屏幕上的绘制区域。
-
7、屏幕图像: 对应屏幕上的绘制区域。
3. WebGL 可视空间
WebGL只会将顶点着色器输出的在一定空间范围内的坐标给绘制出来。这个空间是:在z轴垂直于屏幕朝内、y轴朝上、x轴朝右的坐标系内,由x=-1.0、x=1.0、y=-1.0、y=1.0、z=-1.0、z=1.0这六个平面所确定的立方体。这个立方体就是WebGL的可视空间。请随我一起比划一下刚刚提到那个坐标系,伸出你的左手 ,让食指指向屏幕上,大拇指指向屏幕右,中指指向屏幕朝内。假如用食指表示y轴,大拇指表示x轴,中指表示z轴,那么就是刚提到的坐标系了。因此,WebGL是左手坐标系。
顶点着色器输出的是一个由四个分量表示的坐标,也就是齐次坐标。所以上面提到的满足范围的坐标是指x,y,z分量分别除以w分量之后所表示的坐标,既(x/w,y/w,z/w)。
最终我们在屏幕上看到的图像 是这个立方体可视空间中,所有的像到z=-1.0这个截面上的投影。
左右手坐标系问题: 有人会问了,"不对啊,我平常开发都是基于右手坐标系去开发和思考的,这里怎么就成左手坐标系了?"。这个文档在文章尾部有具体解释,这里因为我刚刚提到了渲染管线的大体流程,趁大家还没忘记,想赶紧通过一个例子带大家更加详细的过一下渲染管线在工作过程中的具体细节。因此,就先不去讲这个左右手坐标系的问题了。如果你想迫切的搞明白这个问题请跳到文章这里
4. 渲染管线渲染一个三角形的详细过程
想象一下,绘制这么一个三角形,我们需要去做那些事情?渲染管线程序需要去做那些事情?
第零步(调用渲染管线程序之前的准备工作)
要绘制一个三角形
首先
需要三个顶点的数据。
其次
需要将数据存入渲染管线可以拿到的地方,也就是缓冲区对象。
然后
需要指定渲染管线,怎么着从缓冲区对象中拿数据。包括:刚开始拿的时候从第几个数据拿、一次拿几个数据、隔几个拿一次 、一共能拿多少次等
再然后
还需要指定绘制什么类型的图元、以那种方式去绘制。
上面提到的图元包括三种:点、线、三角面。点只有一种绘制方式(也就是读一个画一个),线 和 三角面分别有三种绘制方式。
假设V0, V1, V2, V3, V4, V5 是缓冲区中仅有的六个连续的点。
- 那么线图元的三种绘制方式可以表示如下:
- 三角面图元的三种绘制方式可以表示如下:
最后
调用渲染管线程序,执行渲染。
具体操作
(通过WebGL、或封装WebGL的库,编码实现)
-
初始化三个顶点的数据,(0, 0.5), (-0.5, 0.5), (0.5, -0.5)。
-
创建缓冲区对象,存入这三个点的数据。
-
指定顶点着色器,从缓冲区拿3次数据、每次拿2个数据、间隔0个读、刚开始时从第0个数据开始拿。
-
指定渲染的图元类型为"TRIANGLES",也就是绘制三角形,每三个点装配一个三角图元。
-
调用渲染管线程序,执行渲染。
第一步
开始执行渲染管线程序。上面提到,顶点着色器是逐顶点从缓冲区拿取数据;所以首先,第一步是从缓冲区对象中拿出第一个点(0.0, 0.5),处理一下,传入图形装配程序。 这里的顶点着色器非常简单,没有对拿到的顶点做任何处理,直接拿到就输出,如下
js
attribute vec2 a_Position;
void main() {
gl_Position = vec4(a_Position, 0.0, 1.0);
}
- a_Position 是顶点着色器的入参,用来接受从缓冲对象中读到的顶点坐标。
- vec2表示声明一个2二维向量,同理vec3表示声明一个3维向量,vec4表示声明一个4维向量。
- attribute是顶点着色器程序的一种入参类型,常见的入参类型还有uniform。attribute类型的参数只能做为顶点着色器的入参,而uniform类型的参数既能做为顶点着色器的入参数,又能做为片元着色器的入参。
- gl_Position 是顶点着色器的输出,是一个用四个分量(x,y,z,w)表示的顶点坐标。
- 解释一下这行代码"gl_Position = vec4(a_Position, 0.0, 1.0);"
- 这里gl_Position是vec4类型,而传进来的点是vec2类型,所以需要去构建一个vec4类型的坐标;将z轴坐标设置为了0.0,这样渲染出来的三角形,它就在z=0.0这个平面上。
四个分量(x,y,z,w)表示的坐标也叫齐次坐标,齐次坐标表示的真正3D坐标是(x/w,y/w,z/w)。所以,上面我将gl_Position的w分量设置为了1.0的目的,是为了让渲染出来的坐标就是a_Position表示的坐标。
由于指定的绘制方式是"TRIANGLES";而目前图元装配程序只拿到了一个点,所以还不能完成一个三角形的装配,必须等待拿到三个顶点后,才可以。
第二步
执行顶点着色器,拿取缓冲区对象中的第二个点(-0.5,-0.5)。
第三步
执行顶点着色器,拿取缓冲区对象中的第三个点,(0.5, -0.5)。
第四步
这个时候图形装配程序已经读入了三个顶点,那么图形装配程序就可以开始装配三角形了。
第五步
将装配好的三角形光栅化成片元。片元是对屏幕上将要绘制像素的抽象表示,每一个片元中都存储着绘制这个像素所需要的一些信息,比如位置信息、颜色信息、深度信息等。
这里为了示意,只显示了10个片元。实际上,片元数目就是这个三角形最终在屏幕上所覆盖的像素数量。如果这里,修改绘制类型为"LINES",那么渲染管线就会使用前两个点装配一条线出来,第三个点会被舍弃掉。如果修改绘制类型为"LINE_LOOP",那么渲染管线就会将这三个点装配成首位相连的三条线段,在光栅化阶段就会被光栅化成一个空心的三角形线框。
第六步
片元着色器执行。片元着色器,开始逐片元读取,给其着色,之后会存入颜色缓冲区中。 这里将所有的片元都着为红色。如下,片元着色器。
GLSL
void main() {
// gl_FragColor 是片元着色器的输出值。四个分量分别表示,红、绿、蓝、不透明度。
// 需要注意的时,平常我们所见的颜色通道取值范围一般都是0-255,但是在GLSL中,颜色通道的取值范围是0 - 1。
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // rgba
}
...
...
...
第七步
当缓冲区对象中的所有顶点数据,都走完渲染管线,存入颜色缓冲区之后。webgl就会通知浏览器将颜色缓冲区的像素绘制到屏幕上的绘制区域。
OpenGL中有一个技术,叫双缓存技术。OpenGL会准备两个缓冲区,前缓冲区和后缓冲区,显示器不断的读取前缓冲区的像素画面显示到屏幕上,后缓冲区用于准备下一帧要渲染的像素画面,等后缓冲区准备好一帧画面后,前后缓冲区指针交换,这样准备好的帧画面就会被渲染出来了。
这样做的目的是为了防止闪屏。想象一下,假设GPU处理图像的速度特别慢,渲染一帧画面可能就需要1s,而显示器会每秒钟从GPU像素缓存中读取60次(也就是60HZ),那么你肯定就会看到一帧画面从开始渲染到渲染结束的整个过程,也就是说屏幕上会出现渲染了一半的画面,这就是闪屏。使用双缓存技术可以保证每次从像素缓存中读取的都是完整的一帧图像,很好的解决了闪屏问题。
WebGL没有双缓存的概念,浏览器抹平了这个概念。浏览器大致是这样的干的:对于一帧图像,当所有数据都走完渲染管线变成像素进入颜色缓存区之后,会通知浏览器,浏览器读取颜色缓存区的图像显示到对应的渲染区域。
5 渲染管线中的细节
看到这里,相信你对渲染管线是怎么工作的、有哪些具体的部分,已经有了一个简单、清晰的理解。下面我会结合上面三角形的例子,再补充一些渲染管线执行过程中的细节,让你对渲染管线有更加深入的理解。
5-1 图元裁剪
为了提升渲染管线的执行效率,在光栅化阶段,只会对那些完全 或 部分 处于可视化空间内 的图元进行光栅化。那些部分 或 完全 位于可视化空间外 的图元会被提前裁剪掉。具体来说,完全处于可视化空间外 的图元,会被直接剔除掉。部分处于可视化空间外的图元经裁剪后会生成新的图元。
对于部分在可视化空间外的线图元,会取线图元与可视化空间的交点 与 在可视空间内的点 生成新的线图元。
对于部分在可视化空间外的三角图元,会将存在于可视化空间内的部分三角面,拆分为多个三角面,使其刚好在WebGL的可视空间内。
为了方便讲述,下面以二维空间内的线图元和三角图元的裁剪进行举例。同样的道理,也适用于三维空间的裁剪。
线图元裁剪举例: 点A在可视空间内,点B位于可视空间外,取线段AB与可视空间的交点C,连接AC。AC就是裁剪后新生成的线图元。
三角面图元举例:
如图,三角形ABC处于可视化空间内的部分是四边形 AEFD。可能会被拆分为这样的两个三角形,分别是三角形AED 和 三角形EFD。具体实现细节上,不同图形API、不同的硬件会有不同实现。
图元裁剪时机: 图元装配好之后,紧接着的下一个阶段。如下是添加了图元裁剪阶段后的渲染流水线图示。
5-2 透视除
上面提到,顶点着色器输出的是一个四分量(x,y,z,w)的齐次坐标,在进行完图元装配和裁剪处理之后,保留下来的顶点它仍然是使用四个分量表示的齐次坐标。在进行光栅化之前,需要将它的x、y、z分量都除以w,将它转换为三维坐标形式。这个操作就叫做透视除。
5-3 屏幕映射
屏幕上的WebGL-2维绘制区域 ,它的坐标系原点在左下角,y轴朝上,x轴朝右,默认范围由对应canvas元素的width和height属性确定。
WebGL可视化空间(上面也提到了),它最终会将所有图元到z=-1.0这个截面上的投影显示到屏幕上(就是屏幕上的WebGL-2维绘制区域 )。 对于z=-1.0这个截面确定的二维坐标系来说,它的数据范围是-1.0-1.0、x轴朝右、y轴朝上,与屏幕上的WebGL-2维绘制区域 的坐标系不一样;因此将z=-1.0这个截面上的图片输出到屏幕的过程中,就必然需要做一个映射。这个映射操作会在图元装配 之后,光栅化之前去执行。
比如: 将canvas的width 和 height分别初始化为400和200,也就是说最终绘制的像素点是400x200个。那么z=-1.0的这个截面 中的点(-1.0, -1.0)映射完之后就是屏幕上WebGL-2维绘制区域 中最左下角那个像素点,用坐标(0,0)表示;在WebGL可视空间的z=-1.0的这个截面 中的点(1.0,1.0)映射完之后对应的就是屏幕上WebGL-2维绘制区域中最右上角那个像素点,用坐标(400,200)表示。
因为屏幕上的WebGL绘制区域 是二维的,所以这里只需要将投影至WebGL可视空间的z=-1.0的这个截面上的点的x,y分量对应进行映射就行,z分量不会改变。
5-4 光栅化中的细节
上面提到 "最终显示到屏幕上的是片元(像素),所以还需要将图元转成一个个的片元。如下,是三角图元光栅化的过程。右图每一个小方格代表着一个片元,每一个片元代表最终要显示到屏幕上的一个像素点,每一个片元中存储着对应像素点的一些信息。这些信息包括:屏幕上WebGL-2维绘制区域的二维坐标、WebGL可视化空间范围内的三维坐标、所有varying变量的插值等等。 "
varying变量是顶点着色器的输出,顶点着色器输出的varying变量最终会被片元着色器中同名的varying变量所接收。
假设,现在我要绘制一条从红色渐变到蓝色的线段:
-
初始化两个顶点数据 和 两个对应的颜色数据(红色和蓝色)存入缓存中,编写顶点着色器和片元着色器、设置装配图元的方式为LINES、 设置webgl上下文对应的canvas属性的width和height分别是400和400。
- 取两个顶点数据为(0.0, 0.0, 0.0)、(0.0,1.0,0.0)。
- 取两个颜色数据为(1.0, 0.0, 0.0)、(0.0,0.0,1.0)。
- 顶点着色器这样写:
GLSLattribute vec3 a_Position; attribute vec3 a_Color; varying vec3 v_Color; void main() { v_Color = a_Color; gl_Position = vec4(a_Position, 1.0); }
- 片元着色器这样写:
GLSLvarying vec3 v_Color; void main() { gl_FragColor = vec4(v_Color, 1.0); }
-
顶点着色器第一次执行。
a_Position
从缓存中读取点(0.0,0.0,0.0)。a_Color
从缓存中读取数据(1.0,0.0,0.0)。- 因为着色的操作是片元着色器来执行的,所以这里将拿到的颜色信息a_Color 赋值给 v_Color;顶点着色器输出的varying 类型的v_Color,会被片元着色器中的同名的varying类型的 v_Color所接收。
- gl_Position必须是一个四个分量的齐次坐标,所以这里需要将a_Position拼凑为四个分量的坐标。同时;为了避免透视除的过程中,影响真正的坐标,所以这里将w分量设置为1.0。
- 将输出的顶点坐标进行透视除之后,输出给图元装配程序。
-
顶点着色器第二次执行。
a_Position
从缓存中读取点(0.0,1.0,0.0)。a_Color
从缓存中读取数据(0.0,0.0,1.0)。- 将a_Color 赋值给 v_Color。
- 将a_Position拼凑为四个分量的齐次坐标输出。
- 将输出的顶点坐标进行透视除之后,输出给图元装配程序。
-
图元装配程序执行(已拿到两个点,可以装配为一个线图元了)
- 由点(0.0,0.0,0.0) 和 点(0.0,1.0,0.0)装配一个线图元。
-
进行屏幕坐标映射
- 只对点的x、y分量进行映射。
- 点(0.0,0.0,0.0)映射完之后是(200, 200)。
- 点(0.0,1.0,0.0)映射完之后是(200,400)。 (屏幕2维坐标,原点在左下角)
-
光栅化。为了方便讲述,假设将这条线段光栅化为五个片元。实际上光栅化为几个片元是由对应canvas元素的width 和 height属性决定的(注意不是css的属性的width 与 height)。光栅化后,在横向上会有width个片元;在竖向上会有height个片元;拢共会有width x height片元。
那么插完值后,五个片元对应的WebGL可视空间范围的三维顶点坐标、v_Color的值、屏幕上WebGL-2维绘制区域的二维坐标如下:
可视化空间范围中的三维坐标 | 屏幕上绘制区域的二维坐标 | v_Color的值 | |
---|---|---|---|
第一个片元 | (0.0, 0.0, 0.0) | (200, 200) | (1.0 , 0.0, 0.0) |
第二个片元 | (0.0,0.25,0.0) | (200, 250) | (1.0 , 0.0, 0.0) |
第四个片元 | (0.0, 0.75, 0.0) | (200, 350) | (0.25 , 0.0, 0.75) |
第五个片元 | (0.0, 1.0, 0.0) | (200, 400) | (0.0 , 0.0, 1.0) |
光栅化的过程中的插值方式是线性插值。
屏幕坐标映射完之后,webgl可视空间范围内的点还会继续存在。在片元着色器中可以通过内置变量拿到当前处理的片元所对应的"webgl可视空间内的坐标 " 和 "屏幕上webgl-2d绘制区域"中的坐标。
与之相关联的两个内置变量是gl_PointCoord 和 gl_FragCoord。gl_PointCoord是vec2类型,gl_FragCoord是vec4类型。
gl_PointCoord的x,y分量对应webgl可视空间内的坐标的x和y值。
gl_FragCoord的x,y分量对应屏幕上webgl-2d绘制区域中坐标中 的x和y值。z分量对应webgl可视空间内的坐标 的z值,不过gl_FragCoord的z分量的范围是0.0 - 1.0。这里实际是将-1.0->1.0范围的可视空间坐标的z值映射到了0.0 - 1.0范围内。gl_FragCoord的w分量,目前我这边不了解它是用来干嘛的,感兴趣的朋友可以看这里:registry.khronos.org/OpenGL-Refp...
-
片元着色器执行。由于这条线段被光栅化成了五个片元,所以对应的片元着色器要执行五次。
- 第一次拿到的v_Color是 (1.0, 0.0, 0.0)
- 第二次拿到的v_Color是 (0.75, 0.0, 0.25)
- 第三次拿到的v_Color是 (0.5, 0.0, 0.5)
- 第四次拿到的v_Color是 (0.25, 0.0, 0.75)
- 第五次拿到的v_Color是 (0.0, 0.0, 1.0)
-
(深度、透明度)测试与颜色混合
- 由于这里只渲染一条线段,所以这个阶段的处理程序拿到像素之后直接就输出到了颜色缓存中。不存在深度、透明度、颜色混合相关的计算过程。
-
等待所有的像素都输入颜色缓存中之后,通知浏览器。
-
浏览器调用底层api,将颜色缓冲中的图像渲染到屏幕上。
-
这时在屏幕上,就可以看到渲染出来的线段了。
5-5 深度检测与颜色混合
为了提高效率,深度检测默认是关闭的,需要手动开启。不开启的话,会按照从缓存中读取顶点的顺序,后面读取的顶点会覆盖前面的顶点;因为这样很方便,基本不用计算和判断;即使后面读取的顶点在可视空间内的位置在 先读取顶点的后面。
5-6 完整的渲染管线
所以最后的比较完整详细的渲染管线流程是这个样子。红色方块是基于文章开头的流程图新添加的。
6 左右手坐标系统
webgl规范本身是左手坐标系。但是社区大家伙都习惯基于右手坐标系去做思考、去做开发,所以一些基于webgl的库都对webgl的左手坐标系做了一层封装,使其对使用者表现为右手坐标系,这样我们就可以基于右手坐标系去做开发了。
具体怎么做?
如下,左手坐标系,用左手比划一下,食指指向y轴方向,大拇指指向x轴方向,中指指向的就是z轴方向。在y轴朝上,x轴朝右的情况下,z轴垂直于屏幕指向屏幕内。
这里我们借用物理学中对磁场或电流垂直屏幕(纸面)朝内和朝外的表示方法,既用"圈里面一个叉" 表示垂直于屏幕朝内,用"圈里面一个点"表示垂直于屏幕朝外。
如下,右手坐标系,用右手比划一下,食指指向y轴方向,大拇指指向x轴方向,中指的指向就是z轴的方向。在y轴朝上,x轴朝右的情况下,z轴垂直于屏幕指向屏幕外。
通过上面两幅图,可以得知,在y轴朝向和x轴朝向一致的情况下,左右手坐标系的z轴的朝向是恰好相反的。所以我们基于右手坐标系创建的点,在做完所有的坐标变换之后,最后一步再做一个关于XOY平面的对称变换就可以了(既z值符号取反),就可以完成右手坐标系到左手坐标系的变换。
对称变换矩阵如下:
csharp
[ // 行主序表示
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0,-1, 0],
[0, 0, 0, 1]
]
注意点
- 本篇文章中所使用的着色器语言是GLSL-ES 2.0。WebGL2.0默认支持的是GLSL-ES 3.0的语法,但在threejs中为了编写方便、统一,使用宏定义将3.0语法中区别于2.0语法的相关关键字,映射为了2.0语法中对应的关键字,让使用者可以使用2.0的语法去编写着色器。
- 对于操作透视除 。我在学习的过程发现,在有些文章或书本中,它被描述为在顶点着色器之后立马去执行;而在有些文章或书本中,它被描述在图元装配、裁剪之后,光栅化之前去执行(裁剪过程可能会依赖齐次坐标)。我个人自认为,不需要纠结这一点,只需要知道透视除操作在顶点着色器之后、光栅化之前去执行的就可以了,具体在哪个阶段执行,估计不同的标准、不同的硬件厂商会有不同的实现。
写在最后
我在学习渲染管线的过程中发现,很难通过一本书的描述 或 一篇文章的阅读就搞明白渲染管线是怎么回事。通常都需要对比看好多篇文章,看一本以上的书,去多次理解和思考,才能对其有比较深入且深刻的理解。我学习过程中看了这些文章与书中对于渲染管线的描述,也推荐给你,希望对你有所帮助。
-
《WebGL编程指南》
-
《交互式计算机图形学》------ 基于WebGL自顶向下的方法