【WebGL】深入理解属性和缓冲

881 阅读13分钟

如果对WebGL感兴趣,可以点击easy-webgl关注

前言

WebGL代码由一对着色程序组成,即顶点着色器片段着色器。着色器代码都是在GPU中运行,它们所需的任何数据都需要发送到GPU,在GPU中读取。着色器可以通过下面4种方法获取数据:

  • 属性(Attributes)和缓冲。属性仅适用于顶点着色器
  • 全局变量(Uniforms)。在一次绘制中对所有顶点或者像素保持一致值。适用于顶点着色器和片段着色器
  • 纹理(Textures)。从像素或纹理元素中获取的数据。适用于顶点着色器和片段着色器
  • 可变量(Varyings)。顶点着色器给片段着色器传值的一种方式。给顶点着色器中可变量设置的值,会作为参考值进行内插,在绘制像素时传给片段着色器的可变量

本篇文章主要讲的是属性缓冲缓冲是发送到GPU的一些二进制数据序列,是GPU中的一块内存区域,可以存储任何数据。属性用来指明怎么从缓冲中获取所需数据并将它提供给顶点着色器。

属性

在 WebGL 中,属性是顶点着色器的输入,可以直接给属性赋值,也可以从缓冲中获取数据。有两种方式可以给属性赋值:

  • WebGLRenderingContext.vertexAttrib[1234]f[v]()。这种方式不常用,了解一下即可
  • 缓冲。常用的方式

下面的代码通过gl.vertexAttrib2f给属性赋值绘制一个点:

const canvas = document.getElementById('webgl')
const gl = canvas.getContext('webgl')
const vertexShaderSource = `
    attribute vec2 a_position1;
    void main() {
      gl_Position = vec4(a_position1, 0, 1);
      gl_PointSize = 10.0;
    }
`
const fragmentShaderSource = `
    precision mediump float;
    void main() {
      gl_FragColor = vec4(1,0,0.5,1);
    }
`
const program = initShaders(gl, vertexShaderSource, fragmentShaderSource)
const positionLocation1 = gl.getAttribLocation(program, 'a_position1')

gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT);

gl.useProgram(program)
gl.vertexAttrib2f(positionLocation1, 0.5, 0.5)

gl.drawArrays(gl.POINTS, 0, 1)

结果如下,在(0.5,0.5)坐标上绘制了一个10*10大小的点

image.png

属性在WebGL内部是怎么表示的?

如果用 JavaScript 实现,它们看起来可能像这样:

// 伪代码
const gl = {
  arrayBuffer: null,
  attributeValues: [
    [0, 0, 0, 1],
    [0, 0, 0, 1],
    [0, 0, 0, 1],
    [0, 0, 0, 1],
    [0, 0, 0, 1],
    [0, 0, 0, 1],
    [0, 0, 0, 1],
    [0, 0, 0, 1],
 ],
  vertexArray: {
    attributes: [
      { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0, },
      { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0, },
      { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0, },
      { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0, },
      { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0, },
      { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0, },
      { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0, },
      { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0, },
    ],
    elementArrayBuffer: null,
  },
}

上面的伪代码中:

  • gl.arrayBuffer是全局变量,用来绑定目标缓冲区,就可以对缓冲区传输数据。
  • gl.vertexArray就是顶点数组对象(VAO),WebGL全局只有一个顶点数组对象,所有的着色程序都会从这个顶点数组对象中读取属性。WebGL要求顶点数组对象至少支持8个属性,上面的伪代码只列举了8个属性, vertexArray.attributes数组中的每一项都表示一个属性,每个属性都有自己的状态,包括默认值。默认情况下,属性会从attributeValues中读取值。当我们调用gl.vertexAttrib[1234]f[v]()给属性设置值时,实际上修改的就是attributeValues中的值。gl.attributeValues和gl.vertexArray.attributes中的元素是对应的,比如gl.vertexArray.attributes[0]会从gl.attributeValues[0]中读取值。

属性默认值对象(gl.attributeValues)

WebGL内部只有一个gl.attributeValues对象,这意味着所有的着色程序将共用一个gl.attributeValues。这个对象用来存放属性的默认值。可以通过调用gl.vertexAttrib[1234]f[v]()修改这个对象的值。

通过调用const positionLocation1 = gl.getAttribLocation(program, 'a_position1'),我们可以获取到属性a_position1在顶点数组vertexArray.attributes中的索引。

gl.vertexAttrib2f(positionLocation1, 0.5, 0.5)的伪代码如下:

// 伪代码
gl.vertexAttrib2f = function(location, a, b) {
  const attrib = gl.attributeValues[location];
  attrib[0] = a;
  attrib[1] = b;
};

可以通过下面的代码验证两个着色程序共用一个attributeValues对象。

  const canvas = document.getElementById('webgl')
  const gl = canvas.getContext('webgl')
  const vertexShaderSource1 = `
    attribute vec2 a_position1;
    void main() {
      gl_Position = vec4(a_position1, 0, 1);
      gl_PointSize = 10.0;
    }
  `
  const fragmentShaderSource1 = `
    precision mediump float;
    void main() {
      gl_FragColor = vec4(1,0,0.5,1);
    }
  `

  const vertexShaderSource2 = `
    attribute vec2 a_position2;
    void main() {
      float x2 = a_position2.x - 0.03;
      float y2 = a_position2.y;
      gl_Position = vec4(x2, y2, 0, 1);
      gl_PointSize = 10.0;
    }
  `
  const fragmentShaderSource2 = `
    precision mediump float;
    void main() {
      gl_FragColor = vec4(0,0,0,1);
    }
  `
  const program1 = initShaders(gl, vertexShaderSource1, fragmentShaderSource1)
  const program2 = initShaders(gl, vertexShaderSource2, fragmentShaderSource2)

  const positionLocation1 = gl.getAttribLocation(program1, 'a_position1')
  // 修改attributeValues的值,共用的
  gl.vertexAttrib2f(positionLocation1, 0.5, 0.5)



  gl.clearColor(0, 0, 0, 0)
  gl.clear(gl.COLOR_BUFFER_BIT);

  

  // 使用第一个着色程序绘制粉色的点
  gl.useProgram(program1)
  gl.drawArrays(gl.POINTS, 0, 1)

  // 切换到第二个着色程序,绘制黑色的点。
  gl.useProgram(program2)
  gl.drawArrays(gl.POINTS, 0, 1)

效果如下:

image.png

可以看到两个点挨着。但我们只通过gl.vertexAttrib2f(positionLocation1, 0.5, 0.5)修改过属性a_position1的值,也就是修改了attributeValues[0]的值。默认情况下,属性会从attributeValues中读取值,因此即使我们没有修改过a_position2的值,它也会从attributeValues[0]中读值,而attributeValues[0]已经被a_position1修改成[0.5,0.5,0,1],因此a_position2读出来的也是[0.5,0.5,0,1]

WebGL顶点数组对象(VAO)

WebGL内部只有一个顶点数组对象,即gl.vertexArray。但WebGL提供了OES_vertex_array_object扩展允许我们创建,替换vertexArray。比如在WebGL2中我们可以这样替换:

var vao = gl.createVertexArray();
gl.bindVertexArray(vao);

如果用伪代码实现,如下所示:

gl.bindVertexArray = function(vao) {
   gl.vertexArray = vao ? vao : defaultVAO;
};

我们来实践一下替换VAO,首先看下面的代码:

  const canvas = document.getElementById('webgl')
  // 记得获取的是webgl2上下文
  const gl = canvas.getContext('webgl2')

  const vertexShaderSource = `
    attribute vec2 a_position;
    void main() {
      gl_Position = vec4(a_position, 0, 1);
      gl_PointSize = 10.0;
    }
  `
  const fragmentShaderSource = `
    precision mediump float;
    void main() {
      gl_FragColor = vec4(1,0,0.5,1);
    }
  `

  const program = initShaders(gl, vertexShaderSource, fragmentShaderSource)
  const positionLocation = gl.getAttribLocation(program, 'a_position')

  gl.useProgram(program)
  gl.vertexAttrib2f(positionLocation, 0.0, 0.5)

  // 打开的是defaultVAO中的positionLocation属性,即默认的VAO的属性的状态。
  gl.enableVertexAttribArray(positionLocation)

  // var vao = gl.createVertexArray();
  // gl.bindVertexArray(vao);
  gl.drawArrays(gl.POINTS, 0, 1)

在上面的代码中,我们先调用gl.vertexAttrib2f修改gl.attributeValues中的值。然后调用gl.gl.enableVertexAttribArray(positionLocation)将positionLocation属性的enable状态打开。注意,这里打开的是defaultVAO的属性,即defaultVAO.attributes[positionLocation].enable = true。这里一旦打开,那么意味着属性positionLocation需要从缓冲中读取值(后面会分析这个),而我们又没有提供缓冲数据,因此WebGL渲染应该是要出错的,结果如下:

image.png

如果我们将createVertexArray那两行代码的注释去掉,如下面所示:

  const canvas = document.getElementById('webgl')
  // 记得获取的是webgl2上下文
  const gl = canvas.getContext('webgl2')

  const vertexShaderSource = `
    attribute vec2 a_position;
    void main() {
      gl_Position = vec4(a_position, 0, 1);
      gl_PointSize = 10.0;
    }
  `
  const fragmentShaderSource = `
    precision mediump float;
    void main() {
      gl_FragColor = vec4(1,0,0.5,1);
    }
  `

  const program = initShaders(gl, vertexShaderSource, fragmentShaderSource)
  const positionLocation = gl.getAttribLocation(program, 'a_position')

  gl.useProgram(program)
  gl.vertexAttrib2f(positionLocation, 0.0, 0.5)

  // 打开的是defaultVAO中的positionLocation属性,即默认的VAO的属性的状态。
  gl.enableVertexAttribArray(positionLocation)

  var vao = gl.createVertexArray();
  gl.bindVertexArray(vao);
  gl.drawArrays(gl.POINTS, 0, 1)

可以发现,正常渲染,结果如下,并且是在坐标(0.0, 0.5)处绘制的。这也从侧面反映出,即使切换不同的VAO,属性还是会共用同一个gl.attributeValues对象 image.png

当WebGL绘制时,会从顶点数组对象中查找positionLocation属性,而新的顶点数组对象VAO.attributes[positionLocation].enable默认为false,即从属性默认值对象(gl.attributeValues对象)中读取值,而gl.attributeValues[positionLocation]已经被修改成[0.0,0.5,0,1],所以WebGL将会在坐标(0.0, 0.5)处绘制一个点

既然顶点数组对象中的属性attributes是个数组,那么相应的就有大小,可以通过gl.getParameter(gl.MAX_VERTEX_ATTRIBS)获取WebGL支持的属性数量

关于顶点数组对象(VAO)和attributeValues也可以通过下图直观感受到 image.png

下图中,vertex array中attributes前三个属性的enable都为true,对应的,左边Attribute Values中前三个值都被置灰了,表示不可读取

image.png

顶点数组对象(VAO)有什么好处?

为什么WebGL提供了扩展允许我们创建并替换VAO?实际上,这在一些业务场景中比较常用。

缓冲

缓冲区对象是 WebGL系统中的一块内存区域,我们可以一次性地向缓冲区对象中填充大量的顶点数据,供顶点着色器使用。

为什么需要缓冲?

在解释这个问题前,我们先来看下如何通过gl.vertexAttrib[1234]f[v]()的方式绘制多个点。

  const canvas = document.getElementById('webgl')
  const gl = canvas.getContext('webgl')
  const vertexShaderSource1 = `
    attribute vec2 a_position1;
    void main() {
      gl_Position = vec4(a_position1, 0, 1);
      gl_PointSize = 10.0;
    }
  `
  const fragmentShaderSource1 = `
    precision mediump float;
    void main() {
      gl_FragColor = vec4(1,0,0.5,1);
    }
  `
  const program1 = initShaders(gl, vertexShaderSource1, fragmentShaderSource1)

  const positionLocation1 = gl.getAttribLocation(program1, 'a_position1')

  const positions = [
    0.0, 0.0,
    0.5, 0.5,
    0.5, 0.0,
  ]

  gl.clearColor(0, 0, 0, 0)
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.useProgram(program1)

  for(let i = 0; i < 6;i=i+2){
    console.log(i)
    const x = positions[i]
    const y = positions[i+1]
    gl.vertexAttrib2f(positionLocation1, x, y)

    gl.drawArrays(gl.POINTS, 0, 1)
  }

结果如下:

image.png

在上面的代码中,我们绘制三个点,for循环里调用了三次gl.vertexAttrib2f给属性传值。这意味着CPU和GPU至少有三次通信的过程。如果绘制的点有n个,就需要n次通信传值,效率低下,存在较大的性能瓶颈。

因此,更好的做法是,我们一次性往GPU发送这些顶点数据,并通过设置属性的一些状态,告诉GPU怎么读取这些顶点数据。

如何给缓冲传值

使用缓冲区对象向顶点着色器传入多个顶点的数据,需要遵循以下五个步骤。处理其他对象,比如纹理对象、帧缓冲区对象时的步骤也比较类似:

  • gl.createBuffer。创建缓冲区对象。
  • gl.bindBuffer。绑定缓冲区对象。
  • gl.bufferData。将数据写入缓冲区对象。
  • gl.vertexAttribPointer。将缓冲区对象分配给一个属性(attribute)变量,并告诉属性应该怎么读取缓冲中的数据
  • gl.enableVertexAttribArray。开启属性(attribute)变量。实际上就是告诉webgl,这个属性应该从缓冲中读取数据,如果调用gl.disableVertexAttribArray关闭属性,那属性就会从attributeValues中读取值。

也太繁琐了吧!不知道你是否有很多疑问,为什么要bindBuffer?为什么要enableVertexAttribArray?vertexAttribPointer是干什么的?

别急,在介绍上面各个API前,我们先来看下如何使用缓冲绘制三个点:

  const canvas = document.getElementById('webgl')
  const gl = canvas.getContext('webgl')
  const vertexShaderSource1 = `
    attribute vec2 a_position1;
    void main() {
      gl_Position = vec4(a_position1, 0, 1);
      gl_PointSize = 10.0;
    }
  `
  const fragmentShaderSource1 = `
    precision mediump float;
    void main() {
      gl_FragColor = vec4(1,0,0.5,1);
    }
  `
  const program1 = initShaders(gl, vertexShaderSource1, fragmentShaderSource1)

  const positionLocation1 = gl.getAttribLocation(program1, 'a_position1')

  const positions = [
    0.0, 0.0,
    0.5, 0.5,
    0.5, 0.0,
  ]
  const positionBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW)

  // 设置属性positionLocation1的一系列状态,告诉它应该怎么从缓冲中读取数据
  gl.vertexAttribPointer(
    positionLocation1, 2, gl.FLOAT, false, 0, 0)

  // 告诉webgl,属性positionLocation1应该从缓冲中读取数据,而不是从attributeValues中读取数据
  gl.enableVertexAttribArray(positionLocation1);

  gl.clearColor(0, 0, 0, 0)
  gl.clear(gl.COLOR_BUFFER_BIT);

  gl.useProgram(program1)
  // 告诉webgl绘制三个点
  gl.drawArrays(gl.POINTS, 0, 3)

效果如下:

image.png

这次,我们调用gl.createBuffer在webgl中创建一个缓冲区对象,并调用gl.bufferData往缓冲区发送数据。然后调用gl.vertexAttribPointer告诉属性应该怎么读取值。

那为什么使用缓冲需要这么繁琐的步骤呢?实际上,在webgl内部维护了一系列的全局状态,类似指针,比如gl.ARRAY_BUFFER就是用来操作缓冲。当调用gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)时,实际上就是将全局的gl.ARRAY_BUFFER指向positionBuffer这个内存区域。后续对gl.ARRAY_BUFFER的操作都是对positionBuffer内存的操作,除非调用gl.bindBuffer重新绑定到其他内存区域。gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW)就是给positionBuffer内存设置数据。

gl.bindBuffer伪代码如下:

// 伪代码
gl.bindBuffer = function(target, buffer) {
  switch (target) {
    case ARRAY_BUFFER:
      gl.arrayBuffer = buffer;
      break;
    case ELEMENT_ARRAY_BUFFER;
      gl.vertexArray.elementArrayBuffer = buffer;
      break;
  ...
};
gl.bufferData = function(target, data, type) {
  gl.arrayBuffer = data;
  ...
};

因此,调用gl.bufferData给缓冲区传数据前,一定要先确保已经调用gl.bindBuffer绑定了正确的缓冲区。

gl.vertexAttribPointer 用来设置几乎所有其它属性设置。它实现起来像这样:

// 伪代码
gl.vertexAttribPointer = function(location, size, type, normalize, stride, offset) {
  const attrib = gl.vertexArray.attributes[location];
  attrib.size = size;
  attrib.type = type;
  attrib.normalize = normalize;
  attrib.stride = stride ? stride : sizeof(type) * size;
  attrib.offset = offset;
  attrib.buffer = gl.arrayBuffer;  // !!!! <-----
};

注意,当我们调用 gl.vertexAttribPointer 时,attrib.buffer 会被设置成当前 gl.arrayBuffer 的值。属性就会自动绑定到缓冲区。因此,在调用gl.vertexAttribPointer设置属性的状态前,务必确保已经调用了gl.bindBuffer绑定了正确的缓冲区。

gl.enableVertexAttribArray伪代码如下:

// 伪代码
gl.enableVertexAttribArray = function(location) {
  const attrib = gl.vertexArray.attributes[location];
  attrib.enable = true;
};
 
gl.disableVertexAttribArray = function(location) {
  const attrib = gl.vertexArray.attributes[location];
  attrib.enable = false;
};

attrib.enable设置成true,告诉webgl属性需要从缓冲中读取数据。设置成false,告诉wegbl属性需要从attributeValues中读取数据。

下面可以通过一个例子来验证一下gl.enableVertexAttribArray的作用。

  const canvas = document.getElementById('webgl')
  const gl = canvas.getContext('webgl')
  const vertexShaderSource1 = `
    attribute vec2 a_position1;
    void main() {
      gl_Position = vec4(a_position1, 0, 1);
      gl_PointSize = 10.0;
    }
  `
  const fragmentShaderSource1 = `
    precision mediump float;
    void main() {
      gl_FragColor = vec4(1,0,0.5,1);
    }
  `
  const program1 = initShaders(gl, vertexShaderSource1, fragmentShaderSource1)

  const positionLocation1 = gl.getAttribLocation(program1, 'a_position1')

  const positions = [
    0.0, 0.0,
  ]
  const positionBuffer = gl.createBuffer();
  console.log('position...',positionBuffer)
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW)

  // 设置属性positionLocation1的一系列状态,告诉它应该怎么从缓冲中读取数据
  gl.vertexAttribPointer(
    positionLocation1, 2, gl.FLOAT, false, 0, 0)

  // 告诉webgl,属性positionLocation1应该从缓冲中读取数据,而不是从attributeValues中读取数据
  gl.enableVertexAttribArray(positionLocation1);
  // 当调用enableVertexAttribArray告诉属性从缓冲读取数据,即使再调用vertexAttrib2f设置属性的值,也没用
  // 属性依旧会从缓冲中读取数据
  gl.vertexAttrib2f(positionLocation1, 0.5, 0.5)
  gl.clearColor(0, 0, 0, 0)
  gl.clear(gl.COLOR_BUFFER_BIT);

  gl.useProgram(program1)
  // 告诉webgl绘制1个点
  gl.drawArrays(gl.POINTS, 0, 1)

  // 告诉属性不要再从缓冲中读取数据
  gl.disableVertexAttribArray(positionLocation1);
  gl.drawArrays(gl.POINTS, 0, 1)

我们首先使用缓冲中的数据在(0.0,0.0)处绘制一个点。然后调用gl.disableVertexAttribArray(positionLocation1)告诉WebGL不要从缓冲中读取数据,因此第二次调用gl.drawArrays时,WebGL使用gl.attributeValues中的值,即(0.5,0.5)。

image.png

如何通过缓冲传递坐标和颜色值?

缓冲中不仅可以存储坐标数据,还可以存储颜色,法向量坐标等数据。我们通过一个demo实践一下,如何利用缓冲存储一个点的坐标和颜色值。 下面的代码中,positions每5个数字存储一个点的坐标和颜色数据,其中前两个数字表示坐标,后面三个数字表示这个点的颜色值。

  const canvas = document.getElementById('webgl')
  const gl = canvas.getContext('webgl')
  const vertexShaderSource1 = `
    attribute vec2 a_position;
    attribute vec3 a_color;
    varying vec3 v_color;
    void main(){
        v_color = a_color;
        gl_PointSize = 10.0;
        gl_Position = vec4(a_position, 0.0, 1.0);
    }
  `
  const fragmentShaderSource1 = `
    precision mediump float;
    varying vec3 v_color;
    void main(){
        gl_FragColor = vec4(v_color, 1.0);
    }
  `
  const program1 = initShaders(gl, vertexShaderSource1, fragmentShaderSource1)

  const positionLocation1 = gl.getAttribLocation(program1, 'a_position')
  const colorPosition = gl.getAttribLocation(program1, 'a_color')

  let positions = [
    // x y  r g b 前面两个代表坐标,后面三个代表颜色rgb的值
    -0.5, 0.0, 1.0, 0.0, 0.0,
    0.5, 0.0, 0.0, 1.0, 0.0,
    0.0, 0.8, 0.0, 0.0, 1.0
  ]
  positions = new Float32Array(positions)
  const FSIZE = positions.BYTES_PER_ELEMENT // 4

  const positionBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
  gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW)

  // 设置属性positionLocation1的一系列状态,告诉它应该怎么从缓冲中读取数据
  // 定义点的信息
  gl.vertexAttribPointer(
    positionLocation1,
    2, // size,attribute变量的长度(vec2)
    gl.FLOAT, // type, buffer的数据类型
    false,
    5 * FSIZE, // 每个点的信息所占的bytes
    0
  );
  gl.vertexAttribPointer(
    colorPosition,
    3, // size,attribute变量的长度(vec3)
    gl.FLOAT,
    false,
    5 * FSIZE,
    2 * FSIZE
  );

  // 告诉webgl,属性positionLocation1应该从缓冲中读取数据,而不是从attributeValues中读取数据
  gl.enableVertexAttribArray(positionLocation1);
  gl.enableVertexAttribArray(colorPosition);

  gl.clearColor(0, 0, 0, 0)
  gl.clear(gl.COLOR_BUFFER_BIT);

  gl.useProgram(program1)
  // 告诉webgl绘制3个点
  gl.drawArrays(gl.POINTS, 0, 3)
  gl.drawArrays(gl.TRIANGLES, 0, 3);

结果如下:

image.png

总结

本篇文章介绍了属性和缓冲的基本概念和作用,并深入剖析了顶点数组对象(VAO)和AttributeValues。学习了通过gl.vertexAttrib[1234]fv和缓冲给属性传值。通过伪代码的方式学习WebGL内部的工作原理。

参考