本文为读书笔记第七章下半部分
总目录链接:https://blog.csdn.net/floating_heart/article/details/124001572
下半部分包括以下内容:
- 开启深度检测,使用深度缓冲区进行隐藏面消除,处理物体前后关系
- 深度冲突的产生条件和解决方案(多边形偏移)
- 通过顶点索引绘制图形
如何处理对象的前后关系
相关内容:1. 开启WebGL系统中的深度检测功能和深度缓冲区,用于隐藏面消除,来处理物体前后关系;2.深度冲突的产生条件和解决方案:多边形偏移
相关函数:gl.enable(), gl.disable(), gl.clear()中用按位或符号(|)连接参数, gl.polygonOffset()细节内容:笔者认为,1. 深度冲突实际的原因是深度波动,2.隐藏面消除倾向于较少地消除顶点。这一部分的理解有待计算机图形学方面的验证。
在PerspectiveView_mvp.js
示例中,前面的三角形自动遮挡了后面的三角形。但如果我们调整一下顶点的顺序,会出现什么情况?
如下,新的顶点顺序为:
let verticesColors = new Float32Array([
// 顶点坐标和颜色
// 最前面的三角形
0.0, 1.0, 0.0, 0.4, 0.4, 1.0,
-0.5, -1.0, 0.0, 0.4, 0.4, 1.0,
0.5, -1.0, 0.0, 1.0, 0.4, 0.4,
// 中间的三角形
0.0, 1.0, -2.0, 1.0, 1.0, 0.4,
0.5, -1.0, -2.0, 1.0, 1.0, 0.4,
-0.5, -1.0, -2.0, 1.0, 0.4, 0.4,
// 最后面的三角形
0.0, 1.0, -4.0, 0.4, 1.0, 0.4,
-0.5, -1.0, -4.0, 0.4, 1.0, 0.4,
0.5, -1.0, -4.0, 1.0, 0.4, 0.4,
])
此时后面的三角形反而遮挡了前面的三角形:
这体现了WebGL的一种性质:
为了加速绘图操作,WebGL按照顶点在缓冲区中的顺序来处理它们,后绘制的图形将覆盖已经绘制好的图形。
(如果仔细想一想,这样操作也会造成绘制的冗余,但相比计算深度再绘制更加高效)
如果场景中的对象不发生运动,观察者的状态也是唯一的,那么这种做法没有问题。
但如果程序需要不断移动视点,从不同角度看物体时就无法事先决定对象出现的顺序。为了解决此类问题,WebGL提供了隐藏面消除(hidden surface removal)功能。
隐藏面消除
隐藏面消除的数学原理可见计算机图形学的相关书籍。
WebGL中已经内嵌了隐藏面消除的功能,要开启隐藏面消除功能,需要遵循以下两步:
- 开启深度检测功能,进行隐藏面消除。gl.enable(gl.DEPTH_TEST)
- 在绘制之前,清除深度缓冲区。gl.clear(gl.DEPTH_BUFFER_BIT)
第一步中gl.enable()的函数规范如下:
gl.enable(cap)
开启cap表示的功能(capability)。
参数:
cap: 指定需要开启的功能,有可能是以下几个(更多参数参阅OpenGL Programming Guide一书)
gl.DEPTH_TEST: 深度检测
gl.BLEND: 混合(参见“层次模型”章节)
gl.POLYGON_OFFSET_FILL: 多边形位移(见下一节)等
返回值: 无
错误:
INVALID_ENUM: cap的值无效
与gl.enable()函数对应,gl.disable()函数用于关闭功能,函数规范如下:
gl.disable(cap)
关闭cap表示的功能(capability)。
参数:
cap: 与gl.enable()相同
返回值: 无
错误:
INVALID_ENUM: cap的值无效
第二步中提到了深度缓冲区(depth buffer)。深度缓冲区是一个中间对象,它存储深度信息,帮助WebGL通过深度检测(Depth Test)进行隐藏面消除(Hidden Surface Removal)。由于深度方向通常是Z轴方向,所以有时候我们也称它为Z缓冲区。
和颜色缓冲区一样,开启深度检测之后,在绘制每一帧之前必须清除深度缓冲区,否则会出现错误的结果,清除深度缓冲区的方法如下:
gl.clear(gl.DEPTH_BUFFER_BIT)
我们也可以使用按位或符号(|)同时连接两个缓冲区同时清除:
gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT)
示例程序DepthBuffer.js
示例程序在PerspectiveView_mvp.js
的基础上加入隐藏面消除的代码,其中顶点顺序改为从近到远。程序运行效果与PerspectiveView_mvp.js
一致,部分重要代码展示如下:
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.enable(gl.DEPTH_TEST)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
// 绘制右侧三角形
gl.drawArrays(gl.TRIANGLES, 0, n)
...
开启深度检测比较简单,只需要在第一次绘制操作之前开启消除隐藏面并清空深度缓冲区即可,与颜色缓冲区的设置较类似。
书中指出:
- 在任何三维场景中,都应该开启隐藏面消除,并在适当的时刻清空深度缓冲区(通常是在绘制每一帧之前)。
- 隐藏面消除的前提是正确设置可视空间。
深度冲突
在两个图形深度差距较大时,消除隐藏面很简单就可以完成,但如果几何图形或物体的两个表面极为接近时,会出现新的问题,使得表面看上去斑斑驳驳,这种现象称作深度冲突(Z fighting)。
首先,声明深度冲突产生的条件:
- 三角形坐标设置如下:两个三角形都在z=-5的平面上,互相重叠。
let verticesColors = new Float32Array([
// 顶点坐标和颜色
// 绿色三角形
0.0, 2.5, -5.0, 0.0, 1.0, 0.0,
-2.5, -2.5, -5.0, 0.0, 1.0, 0.0,
2.5, -2.5, -5.0, 1.0, 0.0, 0.0,
// 黄色三角形
0.0, 3.0, -5.0, 1.0, 0.0, 0.0,
-3.0, -3.0, -5.0, 1.0, 1.0, 0.0,
3.0, -3.0, -5.0, 1.0, 1.0, 0.0,
])
- 如果垂直Z轴向负方向看,不会出现深度冲突的现象,此时模型矩阵已经删除,视图矩阵和投影矩阵如下:
// 计算矩阵
viewMatrix.setLookAt(0, 0, 5, 0, 0, -100, 0, 1, 0)
projMatrix.setPerspective(60, canvas.width / canvas.clientHeight, 1, 100)
mvpMatrix.set(projMatrix).multiply(viewMatrix)
效果如下:此时小三角形居于上方
- 如果从反方向来看,也不会出现深度冲突现象,小三角形同样居于上方,相关设置和效果如下:
// 计算矩阵
viewMatrix.setLookAt(0, 0, -16, 0, 0, 0, 0, 1, 0)
projMatrix.setPerspective(60, canvas.width / canvas.clientHeight, 1, 100)
mvpMatrix.set(projMatrix).multiply(viewMatrix)
- 只有视线倾斜交于三角形平面,才偶尔会出现深度冲突效果(不是所有倾斜的情况都会出现):
// 计算矩阵
viewMatrix.setLookAt(3, 1, 5, 0, 0, -1, 0, 1, 0)
projMatrix.setPerspective(60, canvas.width / canvas.clientHeight, 1, 100)
mvpMatrix.set(projMatrix).multiply(viewMatrix)
以上试验说明了两个问题:
问题一:在两个表面“极为接近”时,会出现深度冲突。笔者认为,这里所说的“极为接近”,是图形相邻像素z值出现波动,造成时而一个图形在上,时而另一个图形在上的情况。如果注意顶点的设计,在静止的情况下(甚至z值相同时)基本不会出现深度冲突,但如果视角或物体发生一定变换,那么在变换的过程中,临近像素出现z值微小差异的情况就很有可能(基本一定)发生。
问题二:不论正看还是反看,都是小三角形在上面,排除了内置的微小z值差距的原因。笔者认为,WebGL中倾向于“画出更多的图形”,消除隐藏面操作尽量少地消除顶点,期待更具体的解释。
WebGL提供一种被称为多边形偏移(polygon offset)的机制来解决深度冲突的问题。该机制自动在Z值上加上一个偏移量,偏移量由物体表面相对于观察者视线的角度来决定,启动该机制只需要两行代码:
- 启动多边形偏移。
gl.enable(gl.POLYGON_OFFSET_FILL)
- 在绘制之前指定用来计算偏移量的参数。
gl.polygonOffset(1.0,1.0)
gl.polygonOffset()的函数规范如下:
gl.polygonOffset(factor, units)
指定加到每个顶点绘制后Z值上的偏移量,偏移量按照公式m*factor+r*units计算,其中m表示顶点所在表面相对于观察者的视线的角度,r表示硬件能够区分两个z值之差的最小值。
返回值: 无
错误: 无
这个函数很好理解,如果自己设计应该也比较容易。
所以,使用多边形偏移消除上述第三种情况的深度冲突的方法如下:
-
开启多边形偏移(可以放在启动深度检测处)
-
三角形分两部分绘制,在第二个三角形绘制之前进行偏移
...
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.enable(gl.DEPTH_TEST)
gl.enable(gl.POLYGON_OFFSET_FILL)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLES, 0, n / 2)
gl.polygonOffset(1.0, 1.0)
gl.drawArrays(gl.TRIANGLES, n / 2, n / 2)
效果如下:
如果先绘制后面的大三角形,再多边形偏移,最后绘制小三角形,理所当然小三角形会被遮住(如果改一下参数,就会不一样):
Cube与gl.drawElements()
相关内容:通过顶点索引绘制图形;
相关函数:gl.drawElements()小结:重点在于如何思考索引的抽象数据结构,如何组织数据建立合适的索引结构。(所有的思路都建立在WebGL着色器的系统之上)
前面的内容已经向我们展示了三维方面WebGL的诸多特性,本节我们将考虑如何绘制下图所示的立方体。
如果我们采用gl.drawArrays()方法,可以使用的模式包括gl.Triangles、gl.TRIANGLE_STRIP、gl.TRIANGLE_FAN,此时缓冲区顶点数量最多为36个,最少为14个,而立方体顶点只有8个,使用gl.drawArrays()方法会重复定义顶点。
WebGL提供了一种更好的方案来避免重复定义顶点:gl.drawElements()。
gl.drawElements()与通过顶点索引绘制物体
通过顶点索引绘制物体首先需要建立索引,下图展示了前述立方体的顶点索引结构:
配合顶点索引使用的gl.drawElements()方法的函数规范如下:
gl.drawElements(mode, count, type, offset)
执行着色器,按照mode参数指定的方式,根据绑定到gl.ELEMENT_ARRAY_BUFFER的缓冲区中的顶点索引值绘制图形。
参数:
mode: 指定的绘制方式,与gl.drawArrays()相同
count: 指定绘制顶点的个数(整型数)
type: 指定索引值数据类型:gl.UNSIGNED_BYTE或gl.UNSIGNED_SHORT
offset: 指定索引数据中开始绘制的位置,以字节为单位
返回值: 无
错误:
INVALID_ENUM: 传入的mode参数不是前述参数之一
INVALID_VALUE: 参数count或offset是负数
在使用gl.drawElements()之前,除了需要将顶点数据写入缓冲区绑定到gl.ARRAY_BUFFER之外,还需要把顶点索引数据写入缓冲区绑定到gl.ELEMENT_ARRAY_BUFFER。
示例程序HelloCube.js
示例程序HelloCube.js
与ProjectiveView_mvpMatrix.js
类似,采用金字塔可视空间和透视投影变换,顶点着色器对顶点坐标进行简单变换,片元着色器接收varying变量赋值给gl_FragColor进行着色。部分设计如下:
- 着色器部分,与
ProjectiveView_mvpMatrix.js
相同,包括矩阵对象和顶点坐标、颜色对象:
// 顶点着色器
var VSHADER_SOURCE =
'attribute vec4 a_Position;\n' +
'attribute vec4 a_Color;\n' +
'uniform mat4 u_MvpMatrix;\n' +
'varying vec4 v_Color;\n' +
'void main(){\n' +
' gl_Position = u_MvpMatrix * a_Position;\n' +
' v_Color = a_Color;\n' +
'}\n'
// 片元着色器
var FSHADER_SOURCE =
'precision mediump float;\n' +
'varying vec4 v_Color;\n' +
'void main(){\n' +
' gl_FragColor = v_Color;\n' +
'}\n'
- main()函数中,在传统的流程外开启了隐藏面消除,最后清空颜色缓冲区和深度缓冲区,绘制三角形,主要的设置如下:
...
// 设置背景色并开启隐藏面消除
gl.clearColor(0.0, 0.0, 0.0, 1.0)
gl.enable(gl.DEPTH_TEST)
...
// 创建Matrix4对象
let mvpMatrix = new Matrix4()
// 计算矩阵
mvpMatrix.setPerspective(30, 1, 1, 100)
mvpMatrix.lookAt(3, 3, 7, 0, 0, 0, 0, 1, 0)
// 矩阵传值
gl.uniformMatrix4fv(u_MvpMatrix, false, mvpMatrix.elements)
...
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
// 绘制三角形
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0)
...
- initVertexBuffers()函数部分,顶点坐标和颜色如下:
let verticesColors = new Float32Array([
// 顶点坐标和颜色
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, // v0 White
-1.0, 1.0, 1.0, 1.0, 0.0, 1.0, // v1 Magenta
-1.0, -1.0, 1.0, 1.0, 0.0, 0.0, // v2 Red
1.0, -1.0, 1.0, 1.0, 1.0, 0.0, // v3 Yellow
1.0, -1.0, -1.0, 0.0, 1.0, 0.0, // v4 Green
1.0, 1.0, -1.0, 0.0, 1.0, 1.0, // v5 Cyan
-1.0, 1.0, -1.0, 0.0, 0.0, 1.0, // v6 Blue
-1.0, -1.0, -1.0, 0.0, 0.0, 0.0, // v7 Black
])
- initVertexBuffers()函数部分,顶点索引定义如下:
// 顶点索引
let indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // 前
0, 3, 4, 0, 4, 5, // 右
0, 5, 6, 0, 6, 1, // 上
1, 6, 7, 1, 7, 2, // 左
7, 4, 3, 7, 3, 2, // 下
4, 7, 6, 4, 6, 5, // 后
])
- initVertexBuffers()函数,在配置顶点坐标和颜色之后,需要创建顶点索引的缓冲区对象、绑定缓冲区、存储数据,函数返回值是共需绘制多少个点:
// 写入顶点索引数据
let indexBuffer = gl.createBuffer()
if (!indexBuffer) {
console.log('Failed to create indexBuffer')
return -1
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW)
return indices.length
最终效果如下:
HelloCube.js
细节说明
在这个示例中,顶点坐标和颜色相当于一个数据库,绘制时通过索引来访问既定数据,实际上同样需要绘制6*6=36个顶点,但只需要保持8个顶点的数据即可。下面简单讨论执行细节。
- 缓冲区准备
在WebGL中,通过attribute变量分配函数来指定一个顶点由多长的数据组成,如下:
...
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, FSIZE * 6, 0)
...
gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 6, FSIZE * 3)
...
此处一个顶点由(3+3)个gl.FLOAT数据组成,数据分为两部分,分别对应a_Position和a_Color变量。此时,如果把一个整个缓冲区(gl.ARRAY_BUFFER)内数据作为一个数组,每个顶点的数据就是一个元素,按照元素输入顺序生成索引。如下图右半部分gl.ARRAY_BUFFER。
索引数据被写入缓冲区后绑定到了gl.ELEMENT_ARRAY_BUFFER目标下(不需要分配给变量,也没有变量接收,由绘制函数直接读取),索引数据indices的类型为Uint8Array数组,该数组以索引值的形式存储了绘制顶点的顺序。每个元素代表一个索引值,数据类型为无符号8位整型(可存储0-255数字,超过256个顶点需要使用Uint16Array)。
顶点初始化的函数返回需要绘制的顶点的数量,此处返回indices的length属性即可。
需要绘制的顶点由学者自行确定,需要考虑绘制函数的mode,绘制顺序等,此处采用gl.TRIANGLES方式绘制,虽然需要绘制的顶点多,但便于理解。
在数据初始化(initVertexBuffers())完成后,顶点和索引的抽象结构如下:
缓冲区准备完成后,WebGL系统内部状态如下:
- 绘制操作
绘制操作调用gl.drawElements()函数,操作说明如下:
// 绘制三角形
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0)
在mode(绘制方式)、n(顶点数量)、offset的配置之外,gl.drawElements()还需要声明所读取数据的单个数据类型来决定如何读取gl.ELEMENT_ARRAY_BUFFER下绑定的数据。gl.drawElements()直接访问绑定在gl.ELEMENT_ARRAY_BUFFER下的缓冲区,如果数据类型定义不当,可能会出现很多奇怪的错误。
为立方体每个表面指定颜色
在本节,我们希望为立方体每个面绘制不同的颜色,本例的思路也可以用在绘制不同的纹理中,只需把颜色值换成纹理坐标。
在WebGL中,顶点着色器进行逐顶点的计算,接收逐顶点的信息。所以,我们如果想指定表面的颜色,需要将颜色定义为逐顶点的信息并传给顶点着色器。
但在立方体中,一个顶点同时存在于三个面上,如果想要每个表面的颜色不同,简单的思路是定义三个坐标相同颜色不同的顶点,虽然会造成一定冗余,但似乎没有更方便的方法。此时,新的索引的抽象数据结构如下图所示,比如(x0,y0,z0)同时出现在索引0和4的位置:
示例程序ColoredCube.js
相比于HelloCube.js
,本例在initVertexBuffers()
函数中有一定改动:
- 将顶点坐标和颜色分别存储在两个缓冲区中。(这种方式与存储在一个缓冲区的方式各有利弊,利处在于更加灵活,弊端在于编写繁琐。)
- 顶点、颜色和索引数据有所更改。
- 定义了函数initArrayBuffer()封装缓冲区对象的创建、绑定、数据写入和开启操作。
重要部分的代码如下:
- 顶点、颜色和索引数据准备
// 顶点坐标
let vertices = new Float32Array([
1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, // v0-v1-v2-v3 front
1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, // v0-v3-v4-v5 right
1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, // v0-v5-v6-v1 up
-1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, // v1-v6-v7-v2 left
-1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0, // v7-v4-v3-v2 down
1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0 // v4-v7-v6-v5 back
])
// 顶点颜色
let colors = new Float32Array([
0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, // v0-v1-v2-v3 front(blue)
0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, // v0-v3-v4-v5 right(green)
1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, // v0-v5-v6-v1 up(red)
1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, // v1-v6-v7-v2 left
1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, // v7-v4-v3-v2 down
0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0 // v4-v7-v6-v5 back
])
// 顶点索引
let indices = new Uint8Array([
0, 1, 2, 0, 2, 3, // 前
4, 5, 6, 4, 6, 7, // 右
8, 9, 10, 8, 10, 11, // 上
12, 13, 14, 12, 14, 15, // 左
16, 17, 18, 16, 18, 19, // 下
20, 21, 22, 20, 22, 23 // 后
])
- 将顶点坐标和颜色写入缓冲区:
(封装了initArrayBuffer()方法代替重复操作,此处gl.vertexAttribPointer()函数的后两个参数在两次调用中一致,如果不一致,需要增加initArrayBuffer()方法输入的参数)
...
// 将顶点坐标和颜色写入缓冲区
if (!initArrayBuffer(gl, vertices, 3, gl.FLOAT, 'a_Position')) {
return -1
}
if (!initArrayBuffer(gl, colors, 3, gl.FLOAT, 'a_Color')) {
return -1
}
...
function initArrayBuffer(gl, data, num, type, attribute) {
let buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
let a_attribute = gl.getAttribLocation(gl.program, attribute)
if (a_attribute < 0) {
console.log('Failed to get the storage location of ' + attribute)
return false
}
gl.vertexAttribPointer(a_attribute, num, type, false, 0, 0)
gl.enableVertexAttribArray(a_attribute)
return true
}
- 将顶点索引写入缓冲区:
// 将顶点索引写入缓冲区
let indexBuffer = gl.createBuffer()
if (!indexBuffer) {
console.log('Failed to create indexBuffer')
return -1
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW)
本例重点在于思考索引的抽象结构和如何组织数据形成上述的索引结构,将颜色和坐标分开保存更加灵活,需要批量修改的时候更加方便,也便于理解。
我们如果需要从文件读取数据,也需要考虑如何建立索引结构以完成复杂图形的绘制。
总结
以书中的总结作为本章结尾
这一章引入了深度的概念,将我们带进了三维世界。介绍了如何设置观察者的视点和可视空间,讨论了如何绘制三维对象,简要介绍了世界坐标系和局部坐标系。这一章的示例程序与前几章的绘制二维图形的程序的区别就在于,引入了 Z轴以处理深度信息。
下一章将讨论如何实现三维场景的光照,如何绘制和管理复杂的三维图形。我们将重新回到initShaders()函数,因为现在的你已经具有足够的知识,可以去着色器中大显身手了。