WebGL 理论基础 - 基础概念 下 - 图1

前一篇教程创建了着色器程序,本篇将接着前文,完成 WebGL 使用示例。

准备数据

现在我们已经在 GPU 上创建了一个 GLSL 着色程序,我们还需要给它提供数据。WebGL 的主要任务就是设置好状态并为 GLSL 着色程序提供数据。在这个例子中 GLSL 着色程序的唯一输入是一个属性值 a_position。我们要做的第一件事就是从之前创建的 GLSL 着色程序中找到这个属性值所在的位置。

  1. var positionAttributeLocation = gl.getAttribLocation(program, "a_position");

寻找属性值位置(和全局属性位置)应该在初始化的时候完成,而不是在渲染循环中。

属性值从缓冲中获取数据,所以创建一个缓冲:

  1. var positionBuffer = gl.createBuffer();

WebGL 可以通过绑定点操控全局范围内的许多数据,你可以把绑定点想象成一个 WebGL 内部的全局变量。首先绑定一个数据源到绑定点,然后可以通过绑定点引用该数据源。所以让我们来绑定位置信息缓冲(下面的绑定点就是 ARRAY_BUFFER )。

  1. gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);

现在我们需要通过绑定点向缓冲中存放数据:

  1. // 三个二维点坐标
  2. var positions = [
  3. 0, 0,
  4. 0, 0.5,
  5. 0.7, 0,
  6. ];
  7. gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);

这里完成了一系列事情,第一件事是我们有了一个 JavaScript 序列 positions 。然而 WebGL 需要强类型数据,所以 new Float32Array(positions) 创建了 32 位浮点型数据序列,并从 positions 中复制数据到序列中。然后 gl.bufferData复制这些数据到 GPU 的 positionBuffer 对象上。它最终传递到 positionBuffer 上是因为在前一步中我们我们将它绑定到了ARRAY_BUFFER(也就是绑定点)上。

最后一个参数 gl.STATIC_DRAW 是提示WebGL我们将怎么使用这些数据,WebGL 会根据提示做出一些优化。gl.STATIC_DRAW 提示 WebGL 我们不会经常改变这些数据。

在此之上的代码是 初始化代码。这些代码在页面加载时只会运行一次。接下来的代码是渲染代码,而这些代码将在我们每次要渲染或者绘制时执行。

开始渲染

在绘制之前我们应该调整画布(canvas)的尺寸以匹配它的显示尺寸。画布就像图片一样有两个尺寸。一个是它拥有的实际像素个数,另一个是它显示的大小。CSS决定画布显示的大小。你应该尽可能用 CSS 设置所需画布大小 ,因为这比其它方式灵活的多。

为了使画布的像素数和显示大小匹配,这里使用了一个辅助方法,可以在这里获取更多相关信息

  1. webglUtils.resizeCanvasToDisplaySize(gl.canvas);

我们需要告诉 WebGL 怎样把提供的 gl_Position 裁剪空间坐标对应到画布像素坐标,通常我们也把画布像素坐标叫做屏幕空间。为了实现这个目的,我们只需要调用 gl.viewport 方法并传递画布的当前尺寸。

  1. gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

这样就告诉 WebGL 裁剪空间的 -1 -> +1 分别对应到 X 轴的 0 -> gl.canvas.width和 Y 轴的 0 -> gl.canvas.height

0, 0, 0, 0 清空画布,分别对应 r,g,b,alpha(红,绿,蓝,阿尔法)值,所以在这个例子中我们让画布变透明了。

  1. // 清空画布
  2. gl.clearColor(0, 0, 0, 0);
  3. gl.clear(gl.COLOR_BUFFER_BIT);

我们需要告诉 WebGL 运行哪个着色程序:

  1. // 告诉它用我们之前写好的着色程序(一个着色器对)
  2. gl.useProgram(program);

接下来我们需要告诉 WebGL 怎么从我们之前准备的缓冲中获取数据给着色器中的属性。首先需要启用对应属性:

  1. gl.enableVertexAttribArray(positionAttributeLocation);

然后指定从缓冲中读取数据的方式:

  1. // 将绑定点绑定到缓冲数据(positionBuffer)
  2. gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  3. // 告诉属性怎么从positionBuffer中读取数据 (ARRAY_BUFFER)
  4. var size = 2; // 每次迭代运行提取两个单位数据
  5. var type = gl.FLOAT; // 每个单位的数据类型是32位浮点型
  6. var normalize = false; // 不需要归一化数据
  7. var stride = 0; // 0 = 移动单位数量 * 每个单位占用内存(sizeof(type))
  8. // 每次迭代运行运动多少内存到下一个数据开始点
  9. var offset = 0; // 从缓冲起始位置开始读取
  10. gl.vertexAttribPointer(
  11. positionAttributeLocation, size, type, normalize, stride, offset)

一个隐藏信息是 gl.vertexAttribPointer 是将属性绑定到当前的 ARRAY_BUFFER。换句话说就是属性绑定到了 positionBuffer 上。这也意味着现在利用绑定点将 ARRAY_BUFFER 绑定到其它数据上后,该属性依然从 positionBuffer 上读取数据。

从 GLSL 的顶点着色器中注意到 a_position 属性的数据类型是 vec4

  1. attribute vec4 a_position;

vec4 是一个有四个浮点数据的数据类型。在 JavaScript 中你可以把它想象成 a_position = {x: 0, y: 0, z: 0, w: 0}。之前我们设置的 size = 2,属性默认值是 0, 0, 0, 1,然后属性将会从缓冲中获取前两个值( x 和 y ),z 和 w 还是默认值 0 和 1 。

终于可以让 WebGL 运行我们的 GLSL 着色程序了:

  1. var primitiveType = gl.TRIANGLES;
  2. var offset = 0;
  3. var count = 3;
  4. gl.drawArrays(primitiveType, offset, count);

因为 count = 3,所以顶点着色器将运行三次。第一次运行将会从位置缓冲中读取前两个值赋给属性值 a_position.xa_position.y。第二次运行 a_position.xy 将会被赋予后两个值,最后一次运行将被赋予最后两个值。

因为我们设置 primitiveType(图元类型)为 gl.TRIANGLES(三角形),顶点着色器每运行三次 WebGL 将会根据三个 gl_Position 值绘制一个三角形,不论我们的画布大小是多少,在裁剪空间中每个方向的坐标范围都是 -1 到 1 。

由于顶点着色器仅仅是传递位置缓冲中的值给 gl_Position,所以三角形在裁剪空间中的坐标如下:

  1. 0, 0,
  2. 0, 0.5,
  3. 0.7, 0,

WebGL 将会把它们从裁剪空间转换到屏幕空间并在屏幕空间绘制一个三角形,如果画布大小是 400×300 我们会得到类似以下的转换:

  1. 裁剪空间 屏幕空间
  2. 0, 0 -> 200, 150
  3. 0, 0.5 -> 200, 225
  4. 0.7, 0 -> 340, 150

现在 WebGL 将渲染出这个三角形,绘制每个像素时 WebGL 都将调用片段着色器。我们的片段着色器只是简单设置 gl_FragColor1, 0, 0.5, 1,由于画布的每个通道宽度为8位,这表示WebGL最终在画布上绘制[255, 0, 127, 255]

WebGL 理论基础 - 基础概念 下 - 图2

CodePen 地址

改进

在上例中可以发现顶点着色器只是简单的传递了位置信息。由于位置数据坐标就是裁剪空间中的坐标,所以顶点着色器没有做什么特别的事。如果你想做三维渲染,你需要提供合适的着色器将三维坐标转换到裁剪空间坐标,因为 WebGL 只是一个光栅化 API

你可能会好奇为什么这个三角形从中间开始然后朝向右上方。裁剪空间的 x 坐标范围是 -1 到 +1.这就意味着 0 在中间并且正值在它右边。

至于它为什么在上方,是因为裁剪空间中 -1 是最底端 +1 是最顶端,这也意味值0在中间,正值在上方。

对于描述二维空间中的物体,比起裁剪空间坐标你可能更希望使用屏幕像素坐标。所以我们来改造一下顶点着色器,让我们提供给它像素坐标而不是裁剪空间坐标。

这是我们新的顶点着色器:

  1. <script id="2d-vertex-shader" type="notjs">
  2. attribute vec2 a_position;
  3. uniform vec2 u_resolution;
  4. void main() {
  5. // 从像素坐标转换到 0.0 到 1.0
  6. vec2 zeroToOne = a_position / u_resolution;
  7. // 再把 0->1 转换 0->2
  8. vec2 zeroToTwo = zeroToOne * 2.0;
  9. // 把 0->2 转换到 -1->+1 (裁剪空间)
  10. vec2 clipSpace = zeroToTwo - 1.0;
  11. gl_Position = vec4(clipSpace, 0, 1);
  12. }
  13. </script>

这里有些变化需要注意,我们将 a_position 改成 vec2 类型是因为我们只需要用 xy 值。vec2vec4 有些类似但是仅有 xy 值。

接着我们添加了一个 uniform(全局变量)叫做 u_resolution,为了设置它的值我们需要找到它的位置:

  1. var resolutionUniformLocation = gl.getUniformLocation(program, "u_resolution");

其余变化的应该能从注释中理解。通过设置 u_resolution 为画布的分辨率,着色器将会从 positionBuffer 中获取像素坐标将之转换为对应的裁剪空间坐标。

现在我们可以将位置信息转换为像素坐标,这次通过绘制两个三角形来绘制一个矩形,每个三角形有三个点。

  1. var positions = [
  2. 10, 20,
  3. 80, 20,
  4. 10, 30,
  5. 10, 30,
  6. 80, 20,
  7. 80, 30,
  8. ];
  9. gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);

在我们设置好使用这个着色程序后,可以设置刚才创建的全局变量的值。gl.useProgram 就与之前讲到的 gl.bindBuffer 相似,设置当前使用的着色程序。之后所有类似 gl.uniformXXX 格式的方法都是设置当前着色程序的全局变量。

  1. gl.useProgram(program);
  2. ...
  3. // 设置全局变量 分辨率
  4. gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);

显然我们需要告诉 WebGL 要运行六次顶点着色器来画两个三角形,所以我们将 count 改成 6

  1. // 绘制
  2. var primitiveType = gl.TRIANGLES;
  3. var offset = 0;
  4. *var count = 6;
  5. gl.drawArrays(primitiveType, offset, count);

这里是结果。

WebGL 理论基础 - 基础概念 下 - 图3
CodePen 地址

你可能注意到矩形在区域左下角,WebGL认为左下角是 0,0 。
想要像传统二维API那样起点在左上角,我们只需翻转 y 轴即可。

  1. gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);

现在矩形在我们期望的位置了:

WebGL 理论基础 - 基础概念 下 - 图4
CodePen 地址

希望你能体会到WebGL其实是一个非常简单的API。好吧,“简单”可能是一个不恰当的描述。它做的是一件简单的事,它仅仅运行用户提供的两个方法,一个顶点着色器和一个片段着色器,去绘制点,线和三角形。虽然做三维可以变得很复杂,但是这种复杂只是作为程序员的你,是一种复杂形式的“着色器”。WebGL API 只做光栅化处理,并且在概念上十分容易理解。

这个简单的示例向你展示了怎么给属性和全局变量提供数据。它和多个属性以及多个全局变量的原理一致。在这章教程上篇开始的地方,我们还提到了varyings(可变量)和 textures(纹理),这些将会在随后的教程中出现。