1. 参考
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. 图元装配
根据应用阶段设置的绘制状态,在这个阶段会将顶点按照一定规则组装成面、线或者一个点。一般来说会用三角形划分整个顶点区域,所以这一步也会被称为三角设置。
此时顶点之间的关联信息会被写入图元信息中。
就像这样把顶点连线(实际上并没有真的连线)
6.2. 三角遍历
有了边界信息,现在就可以填充片元(fragment)
了,片元不等于像素,片元包含更多的信息方便渲染。
但是有可能一个像素只有一部分被包含,这个时候会有一些策略来判定该不该划入这个像素,常见的有这些策略:
- Standard Rasterization(中心点被覆盖即被划入片元)
- Outer-conservative Rasterization(只要被覆盖了,哪怕只有一点也被划入片元)
- Inner-conservative Rasterization(完全被覆盖才会被划入片元)
经过划分处理之后,边界会变得不平滑,这时候需要使用抗锯齿
具体就不展开了,作用就是平滑边缘。
除了生成片元,还有一个操作就是进行插值计算,在之前的笔记中也提到过插值计算,只不过是计算的颜色。实际上插值计算可以用于位置、颜色、法线、纹理等。
插值计算的属性对于渲染的效果有很大的影响,比如颜色插值可以实现平滑的着色效果,法线插值可以实现光照和阴影的效果,纹理坐标插值可以实现纹理映射的效果,深度插值可以实现深度测试和隐藏面消除的效果等。
覆盖区域里面有许多个片元(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
的图:
根据上面的代码,将在画面中心创建一个
600*600
的裁剪窗口:
6.4.1.2. 透明度测试
webgl
没有专门的透明度测试,在透明度测试中,允许程序员对片元的透明度值进行检测,仅仅允许透明度值达到设置的阈值后才可以会绘制。
实现的效果就是绘制透明物体。
6.4.1.3. 模板测试
模板测试教程上的解释有点抽象,我们来举例子解释,假如要实现一个描绘物体轮廓的功能。
在没有学习模板测试之前,你可能有很多方案,但是沿着物体表面重新定义一堆顶点并绘制太麻烦了,简单图像还行,复杂的几乎是不可能。
所以我们如果能有一坨橡皮泥(模板缓冲区)
,能将物体整个印上去,相当于做出一个模具(模板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>
因为放大了而且分辨率低,所以看着很模糊。
看看输出结果
每一个元素就是一个像素点的值,总共有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
进制数。
3、使用stencilOp
设置了测试之后的处理。KEEP
表示对应模板缓冲区的值不变,REPLACE
表示使用上一步设置的ref
替换原来模板缓冲区中的值
4、正常绘制
- 由于
stencilFunc
设置的ALWAYS
,所以测试必定会通过。 - 再加上
stencilOp
设置的REPLACE
,所以绘制区域对应模板缓冲区的值为1 - 参考之前输出的颜色缓冲区的值,可以写出测试之后模板缓冲区的值。比如最底下一行像素,红色三角形使用了第三个像素,值设为
stencil
。而配置了ALWAYS
和REPLACE
,所以结果就是ref=1
。 - 修改模板缓冲区是逐片元的,所以没有操作的像素是不会修改值的,默认是
0
,这里为了方便看出哪些被修改了,我用x
表示没有操作的地方。
现在我要再画一个绿色的三角形,除了红色三角形绘制的地方都会被画上:
很模糊的原因主要是分辨率只有5*5
,一个像素可能被多个片元拿去使用了。
但是可以看到中间让出了红色三角形的区域,如果不使用模板测试绿色三角形应该直接覆盖掉了红色:
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、绘制
此时的模板缓冲区值还是绘制红色三角形之后的样子:
新绘制的绿色三角形绘制区域(1表示有像素):
对绘制区域对应模板缓冲区的位置上的值进行比较:
基本上可以看出中间被红色三角形挡住的片元都被舍弃了,当然上面的例子并不准确,最后的显示图像还要经过一些深度测试等等操作才能正确显示。
现在我们来实现描绘物体轮廓:
本来是这样的立方体:
让它高亮:
思路:
- 开启模板测试,渲染物体,将模板缓冲更新为1
- 绘制一个同样的但是放大一点的物体,在不为1的区域绘制
- 把后画的物体的光线设置为红色,看起来明显一些
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
中交换缓冲区是自动的,因为这部分是浏览器在控制。
屏幕一般是逐行扫描刷新,所以在刷新完成前交换缓冲,可能会导致两部分显示不一样的图像,也就是我们常说的图像撕裂
。所以在交换前等待显示器的刷新完成信号,再交换,这种技术就是垂直同步
,这也是为什么开启了它帧率偏低的原因。
为了解决开启垂直同步后的速度问题,又出现了三重缓冲
,有两个后置缓冲区。在等待垂直同步信号到来之前,显卡不停的更新帧,但是是两个后置缓冲区在交换,当垂直同步信号到来,就把最近渲染完成的后置缓冲区拿来显示。