WebGL学习(十五)webgl详细渲染流程

2,095 阅读13分钟

1. 参考

渲染管线介绍参考1

渲染管线介绍参考2

渲染管线介绍参考3

渲染管线介绍参考4

GPU架构

渲染过程 渲染过程 光栅化

2. 渲染管线Rendering Pipeline

看到很多资料教程一上来就会解释什么是渲染管线,说实话云里雾里的,因为里面有很多专业名词。不过经过前面的学习,加上中间遇到的困难查询资料的过程中,或多或少都接触到了一部分。现在回头看会好理解很多。

渲染管线,或者叫它渲染流水线更直观。

大的流程阶段有三个:

graph LR
id1[应用阶段] --> id2[几何阶段] --> id3[光栅化阶段]
style id1 fill:#a2c2f3,stroke:#666
style id2 fill:#acf0a2,stroke:#666
style id3 fill:#f1c27b,stroke:#666

3. 可编程渲染管线和固定渲染管线

在解释上面三个阶段之前,先了解一下可编程/固定渲染管线参考

固定渲染管线:或者称之为配置渲染,之前我们渲染纹理无非就是配置几个固定的参数,如果想要自定义渲染方式就不行

可编程渲染管线:全部由程序控制渲染,在这里面多了一些着色器如:曲面细分着色器几何着色器

之前的学习,或者说webgl中都是固定渲染管线。

4. 应用阶段

应用阶段主要适是和CPU有关系,简单的来说,就是在调用gl.drawxx()之前的一系列设置。

flowchart LR
id[准备场景数据] --> id1[设置渲染状态]-->id3["调用Draw Call</br>将图元信息放入显存"]

classDef default fill:#a2c2f3,stroke:#666;

4.1. 准备场景数据

就是我们之前设置的什么顶点数据变换矩阵等等,这些就是即将传递给gpu图元数据

4.2. 设置渲染状态

比如之前我们设置gl.BLEND、gl.blendFunc设置混合模式就是设置了一种渲染状态,还有开关深度检测等等。

4.3. 调用绘制函数

当我们调用绘制函数后,cpu会将内存中的图元信息写入显存,并调用Draw Call告诉显卡可以渲染了。 每次修改了场景数据和渲染状态就需要重新Draw Call一次。

但是显卡渲染速度比cpu命令调用速度快很多,所以cpu和显卡之间有一个缓存区,来缓存命令。

flowchart LR
cpu[CPU]
commands["<p style='font-weight: bold'>命令缓冲区</p>渲染命令1</br>改变渲染状态</br>渲染命令2</br>"]
gpu[GPU]

cpu --存入一个命令--> commands --执行一个命令--> gpu
classDef default fill:#a2c2f3,stroke:#666;

如果不停的往缓冲区新增命令,这个过程将非常耗时。解决办法就是尽可能得一次性绘制多的图元,这种操作称之为批处理

5. 几何阶段

这个阶段就是顶点着色器的主要舞台了。处理图元的位置信息,怎么映射等。

flowchart LR

s["显存中的图元"]-.->id["顶点着色器"] --> id2["曲面细分着色器"]--> id5["几何着色器"]-->id1[投影]-->id3[裁剪]-->id4[屏幕映射]

classDef default fill:#acf0a2,stroke:#666;

这里的曲面细分着色器几何着色器webgl中是没有的,这两个着色器主要是在可编程渲染管线中使用。

5.1. 顶点着色器

这里主要就是进一些顶点坐标的变换操作。

顶点着色器是完全可编程的,完全由我们编写。但是在顶点着色器中,我们不能创建或者删除顶点,而且也不能获取到顶点之间的关系。因为这样可以保证顶点处理的速度,因为不需要考虑关系和增删。

5.2. 投影

投影剪裁可以复习一下WebGL学习(十一)w分量以及坐标系

通过设置投影矩阵,在顶点着色器中对每个顶点做处理,将他们放入投影空间中。

5.3. 剪裁

裁剪做的事情就比较多了,可以再细分。具体内容也可以参考WebGL学习(十一)w分量以及坐标系

flowchart LR

s["放入投影空间的顶点"]-.->id["裁剪(超出投影空间的点,去除w=0的点)"] --> id2["放入NDC(使用透视除法除以w)"]

classDef default fill:#acf0a2,stroke:#666;

5.4. 屏幕映射

经过上面的处理,正确的顶点x,y,z已经有了,但是屏幕像素不是固定有的1920x1080有的2560×1440,所以需要根据上一部归一化的坐标计算出屏幕的坐标。

注意此时顶点仍然包含z值,对于二维的屏幕来说并不需要,但是对于计算与相机的远近还是需要的。

6. 光栅化阶段

有了正确的坐标点,剩下的就是怎么显示到像素点上。

flowchart LR
id[顶点信息] -.-> id1["图元装配(三角设置)"]-->id2["三角遍历"]-->id3["片元着色器"]-->id4["逐片元操作"]-.->显示器显示

classDef default fill:#f1c27b,stroke:#666;

6.1. 图元装配

根据应用阶段设置的绘制状态,在这个阶段会将顶点按照一定规则组装成面、线或者一个点。一般来说会用三角形划分整个顶点区域,所以这一步也会被称为三角设置。

此时顶点之间的关联信息会被写入图元信息中。

image.png

就像这样把顶点连线(实际上并没有真的连线)

6.2. 三角遍历

有了边界信息,现在就可以填充片元(fragment)了,片元不等于像素,片元包含更多的信息方便渲染。

但是有可能一个像素只有一部分被包含,这个时候会有一些策略来判定该不该划入这个像素,常见的有这些策略:

  • Standard Rasterization(中心点被覆盖即被划入片元)
  • Outer-conservative Rasterization(只要被覆盖了,哪怕只有一点也被划入片元)
  • Inner-conservative Rasterization(完全被覆盖才会被划入片元)

image.png

经过划分处理之后,边界会变得不平滑,这时候需要使用抗锯齿具体就不展开了,作用就是平滑边缘。

除了生成片元,还有一个操作就是进行插值计算,在之前的笔记中也提到过插值计算,只不过是计算的颜色。实际上插值计算可以用于位置、颜色、法线、纹理等。

插值计算的属性对于渲染的效果有很大的影响,比如颜色插值可以实现平滑的着色效果,法线插值可以实现光照和阴影的效果,纹理坐标插值可以实现纹理映射的效果,深度插值可以实现深度测试和隐藏面消除的效果等。

覆盖区域里面有许多个片元(fragment),每个片元包含一个像素,但是一个像素可能对应多个片元(可由多个采样点进行平滑处理)

6.3. 片元着色器(像素着色器)

上一步的片元还不是可以显示的像素,这一步就是根据片元信息,计算颜色。

这一步是可完全编程的,程序可以决定某个像素应该是什么颜色,对于纹理也是一样的。

不过要注意的是,在片元着色器里只能对单个片元操作,无法影响其他片元。

6.4. 逐片元操作

这是最后一步,马上就要显示到屏幕上了。但是在把片元转化成像素显示还需要走几个步骤:

flowchart LR
id[片元] -.-> id1[测试]-->id2[混合]-->id3[颜色缓冲区]-.->显示器显示
classDef default fill:#f1c27b,stroke:#666;

6.4.1 测试

简单的说,测试就是根据某些规则判断哪些片元该舍去。测试步骤还可以分为下面几个:

flowchart LR
i["所有者测试(PixelOwnershipTest)"]-->id["裁剪测试(Scissor Test)"] --> id1["透明度测试(Alpha Test)"]-->id2["模板测试(Stencil Test)"]-->id3["深度测试(Depth Test)"]
classDef default fill:#f1c27b,stroke:#666;

这些过程都是可以编程配置的,也就是都可以开启或者关闭,还能配置如何测试。以便实现各种效果。

所有者测试主要是剔除不属于当前程序的像素运算。

6.4.1.1. 剪裁测试

// 开启裁剪测试
gl.enable(gl.SCISSOR_TEST)
// 设置裁剪窗口
// 裁剪窗口原点在左下角
// 长宽的范围是0到canvas的width、height属性
gl.scissor(300, 300, 600, 600)

原本是一个1200*1200的图:

image.png 根据上面的代码,将在画面中心创建一个600*600的裁剪窗口:

image.png

6.4.1.2. 透明度测试

webgl没有专门的透明度测试,在透明度测试中,允许程序员对片元的透明度值进行检测,仅仅允许透明度值达到设置的阈值后才可以会绘制。

实现的效果就是绘制透明物体。

6.4.1.3. 模板测试

参考 参考2 glStencilFunc规范

模板测试教程上的解释有点抽象,我们来举例子解释,假如要实现一个描绘物体轮廓的功能。

在没有学习模板测试之前,你可能有很多方案,但是沿着物体表面重新定义一堆顶点并绘制太麻烦了,简单图像还行,复杂的几乎是不可能。

所以我们如果能有一坨橡皮泥(模板缓冲区),能将物体整个印上去,相当于做出一个模具(模板mask)。然后绘制轮廓就只需要跟着整个模具的边缘画就行了,这样就简单了许多。

来点代码换个角度解释一下(参考代码):

// 打开模板测试功能(建立模板缓冲区)
const gl = canvas.getContext('webgl', {stencil: true})
// 开启模板测试
gl.enable(gl.STENCIL_TEST);
// 设置测试比较函数
gl.stencilFunc(
   gl.ALWAYS,    // 怎样才算通过,
   1,            // ref参考值
   0xFF,         // mask模板 默认全是1
);
// 设置知道测试结果后怎么处理模板缓冲区值
gl.stencilOp(
   gl.KEEP,     // 模板测试失败后的处理
   gl.KEEP,     // 深度测试失败后的处理
   gl.REPLACE,  // 两个都成功后的处理
);
// 正常绘制
// ....
// 这里我读取了每个像素的颜色缓冲区值,来验证模板测试的结果
const pixels = new Uint8Array(canavs.width * canavs.height * 4)
gl.readPixels(0, 0, canavs.width, canavs.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels)
console.log(pixels.reduce((prev, cur, index) => {
   if(index % 4 === 0) {
      prev.push([cur])
   }else {
      prev[prev.length - 1].push(cur)
   }
   return prev
}, []))

为了方便看结果,我将canvas设置成了5x5大小,并且放大了几倍

<canvas width=5 height = 5 style="transform: scale(20) translate(10px, 10px);border: none"></canvas>

image.png

因为放大了而且分辨率低,所以看着很模糊。

看看输出结果

image.png

每一个元素就是一个像素点的值,总共有5*5=25个像素点。readPixels是从左下角为原点开始读取的,从左到右一行一行往上。

举个例子,最下面一行像素除了中间都是白色的,所以第三个像素值是64,0,0,64。你可能好奇为什么不是设置的(1, 0, 0, 1)转化成8位无符号整数(255, 0, 0, 255),这是因为在颜色缓冲区里面的值已经被插值过了。

不过不影响,大概还是知道是红色,不过要淡一点。

我们分步来解释上面的过程:

1、创建了一个模板缓冲区{stencil: true},默认情况下全是0

2、调用stencilFunc,创建了一个模板mask。并配置了比较方式gl.ALWAYS总是通过测试,参考值ref=1,模板默认值#FF。默认情况下模板缓冲区每一个值是8bit,所以使用两位16进制数。

mask55:[1111111111111111111111111]mask(5*5):\\\quad\\ \begin{bmatrix} 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1 \end{bmatrix}

3、使用stencilOp设置了测试之后的处理。KEEP表示对应模板缓冲区的值不变,REPLACE表示使用上一步设置的ref替换原来模板缓冲区中的值

4、正常绘制

  • 由于stencilFunc设置的ALWAYS,所以测试必定会通过。
  • 再加上stencilOp设置的REPLACE,所以绘制区域对应模板缓冲区的值为1
  • 参考之前输出的颜色缓冲区的值,可以写出测试之后模板缓冲区的值。比如最底下一行像素,红色三角形使用了第三个像素,值设为stencil。而配置了ALWAYSREPLACE,所以结果就是ref=1
  • 修改模板缓冲区是逐片元的,所以没有操作的像素是不会修改值的,默认是0,这里为了方便看出哪些被修改了,我用x表示没有操作的地方。
模板缓冲区:[1111111111×111××111×××1××]模板缓冲区:\\\quad\\ \begin{bmatrix} 1&1&1&1&1\\ 1&1&1&1&1\\ \times&1&1&1&\times\\ \times&1&1&1&\times\\ \times&\times&1&\times&\times\\ \end{bmatrix}\\\quad\\

现在我要再画一个绿色的三角形,除了红色三角形绘制的地方都会被画上:

image.png

很模糊的原因主要是分辨率只有5*5,一个像素可能被多个片元拿去使用了。

但是可以看到中间让出了红色三角形的区域,如果不使用模板测试绿色三角形应该直接覆盖掉了红色:

image.png

gl.stencilFunc
   gl.EQUAL,     // 相等才通过测试  (ref & mask == 对应模板缓冲区的值 & mask)
   0,            // ref = 0
   0xFF,         // mask
);

gl.stencilOp(
   gl.KEEP,    
   gl.KEEP,  
   gl.KEEP,  // 测试通过后保留原来的模板缓冲值
);


gl.uniform4fv(colorLoc, [0, 1, 0, 1]); // green
gl.uniformMatrix4fv(matLoc, false, m4.scaling([0.9, -0.9, 1]));
gl.drawArrays(gl.TRIANGLES, 0, 3);

gl.readPixels(0, 0, canavs.width, canavs.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels)
console.log(pixels.reduce((prev, cur, index) => {
   if(index % 4 === 0) {
      prev.push([cur])
   }else {
      prev[prev.length - 1].push(cur)
   }
   return prev
}, []))

下面依旧来分解:

1、设置stencilFunc

2、设置stencilOp

3、绘制

此时的模板缓冲区值还是绘制红色三角形之后的样子:

[1111111111×111××111×××1××]\begin{bmatrix} 1&1&1&1&1\\ 1&1&1&1&1\\ \times&1&1&1&\times\\ \times&1&1&1&\times\\ \times&\times&1&\times&\times\\ \end{bmatrix}\\\quad\\

新绘制的绿色三角形绘制区域(1表示有像素):

[××1×××11×××111×1111×11111]\begin{bmatrix} \times&\times&1&\times&\times\\ \times&1&1&\times&\times\\ \times&1&1&1&\times\\ 1&1&1&1&\times\\ 1&1&1&1&1\\ \end{bmatrix}\\\quad\\

image.png

绘制区域对应模板缓冲区的位置上的值进行比较:

绿色三角绘制区域对应位置与运算mask[1111111111111111111111111]&(ref=0)=[××0×××00×××000×0000×00000]结果记为R×=1mask:[1111111111111111111111111]&模板缓冲区:[1111111111×111××111×××1××]=[1111111111×111××111×××1××]记为S×=0RS比较,相同的就通过(保留片元)=R:[×=1×0×××00×××000×0000×00000]S:[1111111111×=0111××111×××1××]很明显能够保留的只有这些片元:[11×111××11×××××1×××111×11]绿色三角绘制区域对应位置与运算\\ mask: \begin{bmatrix} 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ \end{bmatrix}\& (ref=0)= \begin{bmatrix} \times&\times&0&\times&\times\\ \times&0&0&\times&\times\\ \times&0&0&0&\times\\ 0&0&0&0&\times\\ 0&0&0&0&0\\ \end{bmatrix}\\结果记为R\\\quad\times=1\\\quad\\ mask: \begin{bmatrix} 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ 1&1&1&1&1\\ \end{bmatrix}\& \quad 模板缓冲区: \begin{bmatrix} 1&1&1&1&1\\ 1&1&1&1&1\\ \times&1&1&1&\times\\ \times&1&1&1&\times\\ \times&\times&1&\times&\times\\ \end{bmatrix} \\ = \begin{bmatrix} 1&1&1&1&1\\ 1&1&1&1&1\\ \times&1&1&1&\times\\ \times&1&1&1&\times\\ \times&\times&1&\times&\times\\ \end{bmatrix}\\记为S\\\quad\times=0\\\quad\\ R 和 S比较,相同的就通过(保留片元)=\\ R:\begin{bmatrix} \times=1&\times&0&\times&\times\\ \times&0&0&\times&\times\\ \times&0&0&0&\times\\ 0&0&0&0&\times\\ 0&0&0&0&0\\ \end{bmatrix} S:\begin{bmatrix} 1&1&1&1&1\\ 1&1&1&1&1\\ \times=0&1&1&1&\times\\ \times&1&1&1&\times\\ \times&\times&1&\times&\times\\ \end{bmatrix}\\\quad\\ 很明显能够保留的只有这些片元:\\\quad\\ \begin{bmatrix} 1&1&\times&1&1\\ 1&\times&\times&1&1\\ \times&\times&\times&\times&\times\\ 1&\times&\times&\times&1\\ 1&1&\times&1&1\\ \end{bmatrix}

基本上可以看出中间被红色三角形挡住的片元都被舍弃了,当然上面的例子并不准确,最后的显示图像还要经过一些深度测试等等操作才能正确显示。

glStencilFunc函数定义

glStencilOp函数定义

现在我们来实现描绘物体轮廓:

本来是这样的立方体: image.png 让它高亮:

msedge_WlQpa0Mcd7.gif 思路:

  1. 开启模板测试,渲染物体,将模板缓冲更新为1
  2. 绘制一个同样的但是放大一点的物体,在不为1的区域绘制
  3. 把后画的物体的光线设置为红色,看起来明显一些
gl.enables([gl.DEPTH_TEST, gl.STENCIL_TEST])
// ....
// 渲染物体,将模板缓冲更新为1
gl.stencilFunc(gl.ALWAYS, 1, 0xFF)
gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE)
gl.clearColor(0.3, 0.3, 0.3, 1)
gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT)
gl.drawElements(gl.TRIANGLES, this.indexBufferLength, gl.UNSIGNED_BYTE, 0);
// 渲染边缘,在不等于1的位置渲染
 gl.stencilFunc(
    gl.EQUAL,
    0,
    0xFF,  
  );
  gl.stencilOp(
    gl.KEEP,
    gl.KEEP,
    gl.KEEP, 
  );
// outlineBoxMvpMat放大1.05倍
const mvpMat = gl.getUniformLocation(program, 'mvpMat')
gl.uniformMatrix4fv(mvpMat, false, outlineBoxMvpMat)
// 设置环境光颜色
const ambientColor = gl.getUniformLocation(program, 'ambientColor')
gl.uniform4fv(ambientColor, [1, 0, 0])
gl.drawElements(gl.TRIANGLES, this.indexBufferLength, gl.UNSIGNED_BYTE, 0);

6.4.1.4. 深度测试

顾名思义,就是可以控制物体之间的前后,遮挡关系。

使用方法和前面的测试很像,也是一个比较函数,一个配置函数。

// 隐藏深度较小的,默认是less
gl.depthFunc(gl.LESS);
// 将深度映射到(默认)[0, 1],改变这个映射可以提高精度
gl.depthRange(0.5, 1);
// 开关写入深度缓冲区
gl.depthMask

注意的是,深度值越大,离观察者越远。

在进行着色器之前,其实是已经可以知道某些表面的深度关系了,比如z值,所以有一种技术Early-Z

但是不能和透明度测试一起使用,假如透明物体A在不透明物体B前面,忽略透明度的情况下是应该隐藏B的,但实际上需要渲染B,所以会进行不必要的计算。

6.4.2 混合

现在我们已经决定好了哪些片元应该留下。

最后就是合成之前的处理,一种方式是直接替换颜色缓冲区的值,还有一种就是根据关系混合blend颜色,在混合中可以进行各种加减颜色。

7. 显示

混合完成之后,所有的数据将会写入帧缓冲区frameBuffer下一节会详细解释

如果屏幕正在绘制,直接刷新显示可能会导致显示不完全,所以一般GPU会有两个甚至多个缓冲区,正在显示的缓冲区称为前置缓冲区,而等待替换的缓冲区被称为后置(离屏)缓冲区。当新的图像来到时,先在离屏缓冲区存放着,等屏幕空闲后再交换前后缓冲区。

webgl中交换缓冲区是自动的,因为这部分是浏览器在控制。

屏幕一般是逐行扫描刷新,所以在刷新完成前交换缓冲,可能会导致两部分显示不一样的图像,也就是我们常说的图像撕裂。所以在交换前等待显示器的刷新完成信号,再交换,这种技术就是垂直同步,这也是为什么开启了它帧率偏低的原因。

为了解决开启垂直同步后的速度问题,又出现了三重缓冲,有两个后置缓冲区。在等待垂直同步信号到来之前,显卡不停的更新帧,但是是两个后置缓冲区在交换,当垂直同步信号到来,就把最近渲染完成的后置缓冲区拿来显示。