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

复合VBO

撰写时间:2023-07-27

修订时间:2026-04-04

上一章中,我们使用2VBOs分别存储了顶点位置数据顶点颜色数据,然后分别传送至着色器中的aPositionaColor顶点属性,以渲染出一个多彩三角形。

我们也可仅使用1VBO来存储顶点位置数据顶点颜色数据,然后再将其绑定到aPositionaColor顶点属性。这种同时绑定多个顶点属性的VBO就称为复合VBO

核心原始代码

代码

我们先来看不借助于辅助类时的代码:

function initVBOs() { let verticesColors = [ 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 1.0, -0.5, -0.5, 0.0, 0.0, 1.0, 0.0, 1.0, 0.5, -0.5, 0.0, 0.0, 0.0, 1.0, 1.0 ]; vbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verticesColors), gl.STATIC_DRAW); } function render() { const FB_SIZE = Float32Array.BYTES_PER_ELEMENT; gl.clear(gl.COLOR_BUFFER_BIT); gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.vertexAttribPointer(program['aPosition'], 3, gl.FLOAT, false, FB_SIZE * 7, FB_SIZE * 0); gl.enableVertexAttribArray(program['aPosition']); gl.vertexAttribPointer(program['aColor'], 4, gl.FLOAT, false, FB_SIZE * 7, FB_SIZE * 3); gl.enableVertexAttribArray(program['aColor']); gl.drawArrays(gl.TRIANGLES, 0, 3); }

不同于之前的例子,变量verticesColors同时存储了顶点位置数据顶点颜色数据,因此我们将此类的VBO称为复合VBO。复合VBO的创建方法与之前均一样。

重点在于对于复合VBO,如何正确地调用vertexAttribPointer方法。

vertexAttribPointer方法详解

方法原型

变量vbo的数据源中,共有3个顶点。

let verticesColors = [ 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 1.0, // V0: position (3), color (4) -0.5, -0.5, 0.0, 0.0, 1.0, 0.0, 1.0, // V1: position (3), color (4) 0.5, -0.5, 0.0, 0.0, 0.0, 1.0, 1.0 // V2: position (3), color (4) ];

构成每个顶点的数据中,均由3个元素来表示顶点位置,4个元素来表示顶点颜色。由于复合VBO由一个VBO同时绑定到多个顶点属性,因此,需两次调用vertexAttribPointer来规范数据提取规则。并且,由于需要从一个数组中分多次分别提取顶点位置与顶点颜色的数据,每次调用该方法都有细微区别。

vertexAttribPointer方法原型如下:

voidvertexAttribPointer
  • GLuintindex
  • GLintsize
  • GLenumtype
  • GLbooleannormalized
  • GLsizeistride
  • GLintptroffset
index
通用顶点属性aPositionaColor的索引值。
示范代码中,分别使用program['aPosition']program['aColor']来获取。
size
通用顶点属性所对应的数组元素数量。可取值为1, 2, 3, 4。初始值为4
示范代码中,aPosition对应有3个数组元素,aColor对应有4个数组元素。
type
数组元素的数据类型。可取值:
  • GLbyte
  • GLubyte
  • GLshort
  • GLushort
  • GLfloat
初始值为GLfloat
normalized
定点数 (fixed data) 是否需要归一化 (normalize)。
如果值为true,则需进行归一化;如果值为false,则直接访问而无需归一化。
定点数是OpenGL ES 2.0中的一种数据类型,用于表示带小数部分的数值,通常使用16.16格式,即16位整数部分 + 16位小数部分。而WebGLAPIGLSL ES着色器语言仅支持浮点数,不支持定点数。因此在WebGL中,应将normalized的值指定为false
stride
通用顶点属性所对应的数组元素所占用的字节总和。也即,每个顶点,共占用多少字节。
示范代码中,每个顶点共使用7个数组元素来分别存储顶点位置数据顶点颜色数据。其中aPosition所对应的顶点位置数据3个数组元素,aColor所对应的顶点颜色数据4个数组元素。则共有7个数组元素。
我们使用Float32Array来存储数据,则应将参数stride的值指定为Float32Array.BYTES_PER_ELEMENT * 7
offset
在每个顶点所占用的字节序列中,各通用顶点属性所对应的偏移值,以字节为单位。
示范代码中,对于每个顶点所占用的字节序列中,aPositioin所对应的顶点位置数据应从第0个元素开始取值,其对应的字节偏移值为Float32Array.BYTES_PER_ELEMENT * 0。则应将参数offset的值指定为Float32Array.BYTES_PER_ELEMENT * 0
对于每个顶点所占用的字节序列中,aColor所对应的顶点位置数据应从第3个元素开始取值,其对应的字节偏移值为Float32Array.BYTES_PER_ELEMENT * 3。则应将参数offset的值指定为Float32Array.BYTES_PER_ELEMENT * 3

参数详解

vertexAttribPointer方法的参数为何如此构成?回看下面的代码就很清晰了:

let verticesColors = [ 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 1.0, -0.5, -0.5, 0.0, 0.0, 1.0, 0.0, 1.0, 0.5, -0.5, 0.0, 0.0, 0.0, 1.0, 1.0 ];

用户提供的数据verticesColors是一个共有21个元素的一维数组。

首先,WebGL得知道:这里共有多少个顶点?

我们通过参数stride指定每个顶点的数据所占用的字节数。每个顶点共有7个数组元素,且类型化数组类型为Float32Array,则

WebGL据此得以知道,共有21 / 7 = 3个顶点。需取3次数据来渲染每个顶点。每渲染一个顶点时,只取7个数组元素的字节数量。

其次,WebGL需要知道,在每次所取出的7个数组元素中,对应于aPosition的数据应取多少个 (size) 数组元素,从哪开始 (offset) 取值;对应于aColor的数据应取多少个 (size) 数组元素,从哪开始 (offset) 取值。

我们通过下面的代码告诉WebGL,对应于aPosition的数据应取3个数组元素,从第0个数组元素开始计算字节偏移值。

let size = 3; let offset = FB_SIZE * 0; gl.vertexAttribPointer(program['aPosition'], size, gl.FLOAT, false, stride, offset);

通过下面的代码告诉WebGL,对应于aColor的数据应取4个数组元素,从第3个数组元素开始计算字节偏移值。

let size = 4; let offset = FB_SIZE * 3; gl.vertexAttribPointer(program['aColor'], size, gl.FLOAT, false, stride, offset);

由此,WebGL掌握了足够的信息,得以清晰地了解了我们的意图。

运行应用

参数stride在简单VBO及复合VBO中的不同含义

注意,简单VBO与复合VBOvertexAttribPointer方法中,形参stride的意义是不一样的。简单VBOstride始终为0,表示所有顶点数据只对应1通用顶点属性,无需再进行拆分。此时,不能将其理解为每个顶点数据所占用的字节总数。因为此时每个顶点数据所占用的字节总数并不为0。而复合VBOstride,为多种元素所构成的字节数量总和。

let vertices = [ 0.0, 0.5, 0.0, -0.5, -0.5, 0.0, 0.5, -0.5, 0.0 ]; // stride seems should be 3, yet actually, stride = 0 gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); let verticesColors = [ 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 1.0, // V0: position (3), color (4) -0.5, -0.5, 0.0, 0.0, 1.0, 0.0, 1.0, // V1: position (3), color (4) 0.5, -0.5, 0.0, 0.0, 0.0, 1.0, 1.0 // V2: position (3), color (4) ]; // stride = 7 gl.vertexAttribPointer(program['aPosition'], 3, gl.FLOAT, false, FB_SIZE * 7, FB_SIZE * 0);

封装

发现封装的需求

在上一章中,如下所示,我们创建2个简单VBOs并进行了渲染:

而在本章中,我们通过创建1个复合VBO并进行了渲染:

两相比较,本章的代码太令人眼花缭乱了。可见,借助于glu的封装功能,上一章的代码凭借优雅、简练的形式而胜出。

封装的目标

我们希望封装后,均能以统一的方式来编写代码。

具体来说,当渲染一个复合VBO时,可编写代码如下:

当渲染多个简单VBOs时,可编写代码如下:

也即,

  1. glucreateVBO能统一应对一个复合VBO或多个简单VBOs。其后面两个参数,参数类型可为单个数值或是数组。
  2. glu原来的bindVboToAttrib方法名重命名为bindVboToAttribs
  3. 在创建VBO时,能为gldrawArrays方法提供共有多少个顶点数量的信息。

下面以此为目标进行重构。

重构WebGLUtils

WebGLUtils-v1.js复制为WebGLUtils-v2.js

在文件的开始部分添加下面代码:

const FB_SIZE = Float32Array.BYTES_PER_ELEMENT;

接着修改createVBO方法,以让其对简单VBO及复合VBO都能适用。

实现的途径是让该方法的形参compsSizesattribsNames均能接收单一的数值或数组。

createVBO(arrData, compsSizes, attribsNames) { let gl = this.gl; let vbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(arrData), gl.STATIC_DRAW); vbo.strideSize = 0; if (Array.isArray(compsSizes)) { compsSizes.forEach(value => vbo.strideSize += value); vbo.verticesNum = arrData.length / vbo.strideSize; vbo.compsSizes = compsSizes; } else { vbo.verticesNum = arrData.length / compsSizes; vbo.compsSizes = Array.of(compsSizes); } if (Array.isArray(attribsNames)) { vbo.attribsNames = attribsNames; } else { vbo.attribsNames = Array.of(attribsNames); } return vbo; }

每个所创建的VBO对象,都将包装以下属性名称:

  • strideSize
  • compsSizes
  • attribsNames
  • verticesNum

3个是vertexAttribPointer方法所需要,最后一个是drawArrays方法所需要。

代码:

vbo.strideSize = 0; if (Array.isArray(compsSizes)) { compsSizes.forEach(value => vbo.strideSize += value); vbo.verticesNum = arrData.length / vbo.strideSize; vbo.compsSizes = compsSizes; } else { vbo.verticesNum = arrData.length / compsSizes; vbo.compsSizes = Array.of(compsSizes); }

根据实参compsSizes是否数组来设置相应的属性值。

如果实参compsSizes是数组,则strideSize的值为各个顶点各成分之和;verticesNum表示顶点的数量,其值等于数组的长度除以每个顶点各成分之和;直接将参数compsSizes的值赋值于属性compsSizes

如果实参compsSizes为单值,则strideSize的值为0verticesNum的值等于数组的长度除以这个单值;通过Array.of(compsSizes)将其转换为数组后赋值于属性compsSizes

同样,代码:

if (Array.isArray(attribsNames)) { vbo.attribsNames = attribsNames; } else { vbo.attribsNames = Array.of(attribsNames); }

根据实参attribsNames是否数组来设置相应的属性值。如果为数组,则直接赋值于vboattribsNames属性;如果不是数组,则转化为数组后再赋值。

经这样转化后,vbocompsSizes属性及attribsNames属性均统一为数组的形式。

删除原来的bindVboToAttrib方法,添加bindVboToAttribs方法,代码如下:

bindVboToAttribs(vbo) { let gl = this.gl; let program = this.program; gl.bindBuffer(gl.ARRAY_BUFFER, vbo); let index = 0; for (let attribName of vbo.attribsNames) { gl.vertexAttribPointer(program[attribName], vbo.compsSizes[index], gl.FLOAT, false, FB_SIZE * vbo.strideSize, FB_SIZE * getOffsetFromCompsSizes(vbo.compsSizes, index)); gl.enableVertexAttribArray(program[attribName]); index++; } function getOffsetFromCompsSizes(compsSizes, index) { let sum = 0; let currIndex = 0; while (currIndex < index) { sum += compsSizes[currIndex]; currIndex++; } return sum; } }

内嵌函数getOffsetFromCompsSizes

function getOffsetFromCompsSizes(compsSizes, index) { let sum = 0; let currIndex = 0; while (currIndex < index) { sum += compsSizes[currIndex]; currIndex++; } return sum; }

根据成分数组及所绑定的顶点属性名称的数组的索引值来返回相应的偏移值。例如,对于

vbo2 = glu.createVBO([ 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 1.0, -0.5, -0.5, 0.0, 0.0, 1.0, 0.0, 1.0, 0.5, -0.5, 0.0, 0.0, 0.0, 1.0, 1.0 ], [3, 4], ['aPosition', 'aColor']);

这样的vbo,该函数将能计算出绑定至第一个顶点属性aPosition的元素偏移值为0,而绑定至第二个顶点属性aColor的元素偏移值为3。如果有更多绑定的顶点属性,该函数均能自动计算出其偏移值。

有此作为支撑,在bindVboToAttribs方法中就能以统一的方式来调用vertexAttribPointer方法。

compound-vbo.html复制为compound-vbo-refactored.html。核心代码如下:

function initVBOs() { let verticesColors = [ 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 1.0, -0.5, -0.5, 0.0, 0.0, 1.0, 0.0, 1.0, 0.5, -0.5, 0.0, 0.0, 0.0, 1.0, 1.0 ]; vbo = glu.createVBO(verticesColors, [3, 4], ['aPosition', 'aColor']); } function render() { gl.clear(gl.COLOR_BUFFER_BIT); glu.bindVboToAttribs(vbo); gl.drawArrays(gl.TRIANGLES, 0, vbo.verticesNum); }

经重构后,连gldrawArrays方法也可直接从vbo中提取所需数据作为参数使用,这为以后更高阶的数据封装打下了基础。

运行应用

重构后的简单VBO

为测试重构后的代码也能适用于简单VBO,我们新建一个名为simple-vbo-refactored.html的文件,主要代码如下:

function initVBOs() { verticesVBO = glu.createVBO([ 0.0, 0.5, 0.0, -0.5, -0.5, 0.0, 0.5, -0.5, 0.0 ], 3, 'aPosition'); colorsVBO = glu.createVBO([ 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0 ], 4, ['aColor']); } function render() { gl.clear(gl.COLOR_BUFFER_BIT); glu.bindVboToAttribs(verticesVBO); glu.bindVboToAttribs(colorsVBO); gl.drawArrays(gl.TRIANGLES, 0, verticesVBO.verticesNum); }

可以看到,在创建verticesVBO时,createVBO的最后一个参数是一个字符串,而创建colorsVBOcreateVBO最后一个参数是仅带有1个元素的字符串数组,这些方式在createVBO方法中均得到了支持。

运行应用

Playground

在下面的WebGLEditor中,顶点着色器、片断着色器以及主程序均与上节一致,试着改变其中一些变量,然后按Run按钮,观察修改后的效果。

attribute vec4 aPosition; attribute vec4 aColor; varying vec4 vColor; void main() { gl_Position = aPosition; vColor = aColor; }
precision mediump float; varying vec4 vColor; void main() { gl_FragColor = vColor; }
import WebGLUtils from '/tutorials/webgl/fundamentals/examples/js/esm/WebGLUtils-v2.js'; let glu, gl; let verticesVBO, colorsVBO; init(); function init() { initContext(); initVBOs(); render(); } function initContext() { glu = new WebGLUtils({ vertexAttribs: ['aPosition', 'aColor'], renderFunc: render }); gl = glu.gl; gl.clearColor(0, 0, 0, 1); } function initVBOs() { verticesVBO = glu.createVBO([ 0.0, 0.5, 0.0, -0.5, -0.5, 0.0, 0.5, -0.5, 0.0 ], 3, 'aPosition'); colorsVBO = glu.createVBO([ 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0 ], 4, ['aColor']); } function render() { gl.clear(gl.COLOR_BUFFER_BIT); glu.bindVboToAttribs(verticesVBO); glu.bindVboToAttribs(colorsVBO); gl.drawArrays(gl.TRIANGLES, 0, verticesVBO.verticesNum); }
canvas { display: block; width: 100%; height: 450px; }

修改CSS面板中height属性值,可调节输出面板的高度。

参考资源

  1. WebGL 1.0 Specification
  2. OpenGL ES 2.0 Reference Pages