Web编程技术营地
研究、演示、创新

drawElements

撰写时间:2023-08-02

修订时间:2026-06-05

drawArrays方法只能从数组的特定起始位置取出连续的特定数量的元素来构建基本图元,我们不能从数组中随意挑选元素来构建图像,因此很不方便。

渲染方法除了drawArrays后,还有一个drawElements方法,该方法允许我们通过指定数组元素索引值的方式,引用任意次序、任意数量的数组元素来构建图元。因此在构建较为复杂的图元时,drawElements方法更为方便。

全局变量与应用入口

import WebGLUtils from './js/esm/WebGLUtils-v4.js'; import defaultExport from '/js/esm/ArrayExtensions.js'; import { GLColors } from '/js/esm/GLColors.js'; let glu, gl; let vao; let ibo; init(); function init() { initContext(); initVAOs(); initIBOs(); render(); }

与前面章节的例子相比,多了一个全局变量ibo,以及一个initIBOs函数。

initVAOs函数

function initVAOs() { let verticesVBO = glu.createPosVBO([ 0.0, 0.0, 0.0, // V0 -0.5, 0.5, 0.0, // V1 -0.5, -0.5, 0.0, // V2 0.5, -0.5, 0.0, // V3 0.5, 0.5, 0.0 // V4 ]); let colorsVBO = glu.createColorVBO(GLColors.GetRandomSoftRGB().repeat(verticesVBO.verticesNum)); vao = glu.createVAO(verticesVBO, colorsVBO); }

verticesVBO的客户端数据是一个有5个顶点的数组,顶点VO位于NDC坐标系的原点位置,顶点V1V4分别对应于NDC坐标系的左上角、左下角、右下角、右上角的位置。

在本例中,我们准备取VO, V1, V23顶点渲染为一个三角形,取VO, V3, V43顶点渲染为另一个三角形。现在,如果将这两组顶点索引值组成一个数组,则该数组各元素值如下:

下一节,我们将根据此数组来创建一个IBO (index buffer object, 索引缓冲区对象),并最终调用gl.drawElements方法来渲染IBO中所指定的各个顶点。

而不管是否使用IBO,创建VAO的方式与之前完全一样。

可见,VAO不会记住IBO的状态,这反倒允许我们对一个VAO自由指定各种IBOs

initIBOs函数

function initIBOs() { let indices = [ 0, 1, 2, 0, 3, 4 ]; ibo = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW); }

根据上节中所需要构建的indices数组,创建了一个IBO并赋值于全局变量ibo

创建IBO的代码与创建VBO的代码很相似。但有两个地方不一样。一是绑定的target不一样,创建IBO需要绑定ELEMENT_ARRAY_BUFFER。二是由于索引值的数据类型是整数的类型化数组,因此这里选用了无符号16位的类型化数组Uint16Array。意味着我们可在indices变量中存储216 = 65536个数组元素。

render函数

function render() { gl.clear(gl.COLOR_BUFFER_BIT); gl.bindVertexArray(vao); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); let indicesElementsToBeUsed = 6; let indicesElementOffset = 0; gl.drawElements(gl.TRIANGLES, indicesElementsToBeUsed, gl.UNSIGNED_SHORT, Uint16Array.BYTES_PER_ELEMENT * indicesElementOffset); }

在渲染时,先调用bindVertexArray绑定VAO,接着调用bindBufferIBO绑定至gl.ELEMENT_ARRAY_BUFFER,最后调用drawElements方法进行渲染。

需要注意的是,drawElements方法的参数数量与位置与drawArrays方法不一样。drawElements方法的原型如下:

voiddrawElelements
  • GLenummode
  • GLsizeicount
  • GLenumtype
  • GLintptroffset

参数mode指定要构建的基本图元类型。

参数count指定要使用索引数组中的元素数量。

参数type指定索引数组元素的类型。可选值为UNSIGNED_BYTEUNSIGNED_SHORT,及UNSIGNED_INT。前面我们使用Uint16Array来创建IBO,故这里应使用对应的常量UNSIGNED_SHORT

参数offset指定开始的偏移位置,以字节数表示。

一般来讲,以字节数指定偏移值不够直观,较为直观的是从第几个元素开始。设变量elementOffset代表第几个元素,则该元素的字节偏移值可通过下面代码计算出来:

对于数组:

代码:

表示,从indices索引数组中,共取用6个数组元素,从第0的元素的偏移处开始,用以构建多个独立的三角形。每个三角形由3个顶点构成,由此最终所渲染的三角形的数量为:6 ÷ 3 = 2个三角形。

若修改代码为:

则将只渲染右边的三角形。

注意,在将indicesElementOffset的值改为3后,则indices数组中只剩下3个元素,因此indicesElementsToBeUsed的值也应随之而改小,不能再为6了。否则将抛出:

的异常。以后当看到此异常,排查线索为:若从第indicesElementOffset个元素开始,此时indices数组中所剩下的数组元素数量是否仍大于等于所指定的indicesElementsToBeUsed?

运行应用VO, V1V2构建了左边的三角形,VO, V3V4构建了右边的三角形。

在这里,drawElements方法的便利之处体现在两方面。一是在一条渲染语句中,顶点VO使用了两次。二是VOV3在原来的顶点数组中是不相连的,但我们却可以随意提取出来自由组合。这些便利之处,是drawArrays方法所不具备的。

这类似于我们先在电路板上整齐在排列好各个电子元件,再通过烧制电路板及焊接技术,将原来不相连的电子元件连起来,从而制作出功能不一的电子产品。或者说,我们可以将各个顶点自由地穿针引线了。

将IBO封装进VAO

一般来讲,IBO独立存在没有太大意义,它应从属于VAO,因此IBO可以作为VAO的一个属性而存在。如果这样,原来WebGLUtilsrenderVAO方法可以将此情况也涵盖进来。

WebGLUtils-v4.js复制为WebGLUtils-v5.js。在文件第一行之后,添加一行:

const FB_SIZE = Float32Array.BYTES_PER_ELEMENT; const U16B_SIZE = Uint16Array.BYTES_PER_ELEMENT;

添加createIBO方法:

createIBO(vao, indices) { const { gl } = this; let ibo = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW); ibo.indicesNum = indices.length; vao.ibo = ibo; }

创建一个ibo后,将其indicesNum属性值设为索引数组的长度,以方便drawElements方法调用。并将传进来的参数vaoibo属性值设置为此ibo

修改renderVAO方法如下:

renderVAO(vao) { const { gl } = this; gl.bindVertexArray(vao); if (vao.ibo) { gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vao.ibo); gl.drawElements(gl.TRIANGLES, vao.ibo.indicesNum, gl.UNSIGNED_SHORT, U16B_SIZE * 0); } else { gl.drawArrays(gl.TRIANGLES, 0, vao.verticesNum); } }

如果参数vaoibo属性,则调用drawElements来渲染,否则,调用drawArrays方法来渲染。

drawElements.html复制为vao-refactored.js。修改相应代码如下:

import WebGLUtils from './js/esm/WebGLUtils-v5.js'; ... init(); function init() { initContext(); initVAOs(); render(); } ... function initVAOs() { let verticesVBO = glu.createPosVBO([ 0.0, 0.0, 0.0, // V0 -0.5, 0.5, 0.0, // V1 -0.5, -0.5, 0.0, // V2 0.5, -0.5, 0.0, // V3 0.5, 0.5, 0.0 // V4 ]); let colorsVBO = glu.createColorVBO(GLColors.GetRandomSoftRGB().repeat(verticesVBO.verticesNum)); vao = glu.createVAO(verticesVBO, colorsVBO); glu.createIBO(vao, [ 0, 1, 2, 0, 3, 4 ]); } function render() { gl.clear(gl.COLOR_BUFFER_BIT); glu.renderVAO(vao); }

在像之前一样创建vao后,glucreateIBO方法根据索引数组创建了一个IBO,并设置为vao的属性值。渲染时,只需以vao作为参数调用glurenderVAO方法就行了。

运行应用

修改上面glu.createIBO()方法的索引数组的值,运行应用,观察效果。重构后,我们将上面各节所有的技术细节都浓缩为一条glu.createIBO()语句调用,由于用户只需在这仅有的一个地方输入索引数组值,从用户的角度,他会感到很放心,不会担心代码相互污染。再将这段代码屏蔽掉,应用程序照样正常运行。应用程序的功能扩展了,同时保持了很好的向下兼容性。

drawElements方法在WebGL编程中的作用举足轻重,结合WebGL 2.0VAO的新功能,我们得以最大限度地挖掘其潜力与功效。

drawElementsInstanced

WebGL 2.0drawElementsInstanced方法是实现实例化渲染的核心方法。它允许我们在客户端只需调用一次渲染命令,却可一下子渲染出同一网格物体的多个副本(实例)。

基本用法

顶点着色器代码:

对传进来的顶点位置,让其根据gl_InstanceID的值而产生不同的偏移值。

要渲染的网格物体为一个正方体:

构建2个三角形的索引值:

render函数代码为:

运行应用

instanceCount作为参数传给drawElementsInstanced方法,则该方法只需被调用一次,却能最终渲染出3个正方形。

WebGL内部,代码:

将等效于执行以下代码:

其中,drawElementsOneInstance是一个虚拟的方法,表示将调用3次渲染一个实例的方法。

顶点着色器中,gl_InstanceIDGLSL的预定义变量,专用于接收drawElementsOneInstance方法中实参i的值。因此,对于3次实例渲染中的每次渲染,gl_InstanceID的值将分别取自于[0, 1, 2]

由此,每渲染一个实例时,offsetX的值变化如下:

然后,将此值转换为vec4后,与aPosition相加后再传递给gl_Position,则最终渲染出水平位置各不相同的3个实例。

与我们在客户端多次循环调用drawElements相比,drawElementsInstanced方法可以大幅减少CPUGPU之间的调用开销,并允许GPU并行处理实例。

为各实例指定不同颜色

上一节中,我们向顶点着色器aPosition只传入一个正方形网格物体的4个顶点。但通过gl_InstanceID,生成了3个不同的偏移值,从而导致生成了3组不同的顶点坐标,最终在3个不同的位置渲染出3个实例。

本节中,我们不借助于gl_InstanceID,而是让客户端一下子传进多组不同的颜色值,但让WebGL在渲染每个实例时,依序从这些颜色值中选出一种颜色值,从而让所渲染的3个实例,不仅位置不同,颜色也不相同。

只需修改initVAOs函数内容即可。

verticesVBO的代码未变,只需传入一个正方形网格物体的4个顶点。下面看颜色值的设置。

数组colors的值为4RGBA值,也即其共有4组不同的颜色值。

这个我们知道,4个顶点,4个颜色值,因此上面代码为每个顶点各分配了一个不同的颜色值。😎,呵呵

下面是创建并设置vao的代码:

render函数未变,仅在此列出:

运行应用,渲染出下面的效果图。

instanced-diff-colors-1

渲染了3个实例,每个实例的顶点位置不一样。每个实例中,每个顶点的颜色值依序为红、绿、蓝、紫。

我们为colors仅设置了4种颜色值,在渲染每个实例中,colors数据均被重复使用了。因渲染次数为3,因此,colors的数据被重复使用了3次。

但有问题。尽管每个实例每个顶点的颜色值都不一样,但每个实例的颜色值都一样。这不是我们想要的效果。我们希望3个正方形的颜色值不一样。

initVAOs函数中下面这行代码取消屏蔽:

运行应用,渲染出下面的效果图。

instanced-diff-colors-2

在调用该行代码后,aColor顶点属性的值,在渲染实例时,divisor个实例更新一次,而不是每顶点更新一次。

具体来说,渲染每个实例时,WebGL会从colorsVBO中根据实例索引gl_InstanceID读取下一组RGBA值(即4个浮点数),并将该RGBA值作为该实例所有顶点的颜色值。

由于colors的值为:

因此,渲染第一个实例时,第一组RGBA颜色值[1, 0, 0, 1]将被读取,并且,被扩充为4个顶点的颜色值:

然后,将instanceColors的颜色值分别与实例的4个顶点相映射。因此,第一个实例被渲染为红色。

同理,第二个实例取出绿色的RGBA颜色值,扩充为4组颜色值后,填充第二个实例。第三个实例取出蓝色的RGBA颜色值,扩充为4组颜色值后,填充第三个实例。最后一个紫色的RGBA颜色值,因无更多实例需要渲染,被舍弃不用。

让 WebGLUtils 支持实例化 VBO

对于任何一个VBO,默认情况下也会对其调用vertexAttribDivisor方法,只不过此时实参divisor的值为0。对于以非0值的实参调用vertexAttribDivisor方法的VBO,我们称之为实例化VBO

根据此特性,可将WebGLUtils类中创建VBO的各个方法作适当修改:

创建VBO时,为第2个形参divisor(s)设置默认值为0,而在bindVboToAttribs方法中,均统一显式地为它们调用vertexAttribDivisor方法。

由此,上节中的initVAOs函数可修改为:

运行应用

verticesVBO的实例化问题

verticesVBO不实例化

上一节中verticesVBO没有实例化,仅colorsVBO进行了实例化。但其顶点着色器代码为:

顶点着色器直接设置偏移值,将严重束缚客户端的自由。故此,将其代码恢复为:

运行应用。将只看到一个蓝色的正方形。

在此再次列出render函数内容:

共有3次的实例渲染。由于verticesVBO未实例化,则每次实例渲染时,都会在原点位置完整地渲染整个正方形。因此,在原点位置渲染了3次正方形。而因为colorsVBO已经实例化,则先后渲染出了红、绿、蓝的3个正方形。由于它们的位置完全重叠,故只看到了最后一次的蓝色正方形。

verticesVBO 实例化

在上节的基础上,只修改以下代码,以将verticesVBO实例化。

运行应用。黑屏,什么都看不见了。

verticesVBO实例化后,在渲染第1个实例时,将提取第0个顶点的数据:

并将此顶点数值扩充赋值给网格物体其他的3个顶点。也即说,网格物体的所有4个顶点都坍缩为1个顶点。而以此渲染为一个三角形,则导致看不到渲染效果。

因此,对verticesVBO进行实例化,没有太大的实际意义。

但这并不意味着,在实例化渲染时,我们不能灵活地改变网格物体实例的位置。

在客户端改变实例位置

通过在顶点着色器中引入一个表示顶点位置偏移值的变量,我们就可以实现在客户端灵活地改变顶点位置的目标。

顶点着色器着色器代码:

对于唯一的一个表示顶点位置的aPosition,每次的实例渲染都将传进一个表示顶点位置偏移值的aInstanceOffset,两者相加后,则每个实例的顶点位置将产生变化。

客户端主要代码:

NDCUtils.GetColsPositions方法,将根据实例数量,返回在NDC坐标系中居中排列的X轴上的系列值。instanceOffsetVBO根据这些值构建了一系列的顶点坐标,并创建为实例化VBO

运行应用。屏幕上渲染出了20条位置、颜色均不一样的垂直线段。

instanceCount的值改为20000次试试看,屏幕几乎都不带闪烁的。毕竟,客户端向GPU发送命令只有一次,剩下的全由GPU在高速公路上狂奔了。

这种技术,是实现高效渲染粒子效果、烟雾的基石。

参考资源

  1. WebGL 2.0 Specification