从关键概念开始,带你轻松入门 WebGL

2,984 阅读23分钟

只要理解了 WebGL 背后的概念,学习 WebGL 并没有那么难。很多 WebGL 入门文章并没有介绍这些重要的概念,直接使用 WebGL 复杂的 API 开始渲染图形,很轻松就把入坑文变成了劝退文。这篇文章将会着重讲解这些概念,并一步步探究 WebGL 是如何渲染图片到屏幕的,理解这些重要的概念,将会大大降低学习曲线。

什么是 WebGL?

WebGL 可以用来在网页上绘制和渲染复杂的图形或者进行大量计算,它完全集成到浏览器的所有网页标准中,无需安装任何插件即可使用。由非营利 Khronos Group 设计和维护。WebGL 除了应用在图形渲染,如游戏、数据可视化、地图、AR/VR等等,还能应用在深度学习等需要大量计算的场景。

我们知道在网页中可以用 canvas 来画一些 2d 图形。

const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d') // 建立一个二维渲染上下文
// 现在我们就可以用 ctx 来画图形
ctx.fillRect(0, 0, 100, 100) // 画一个方块

我们看见上方获取上下文的参数传的是 2d。所以一般都会认为它只能用来在网页上画 2D 图形,而 WebGL 才能画 3D 图形。其实真实情况是,我们完全可以用 2d 来画 3D 图形,甚至是在终端上使用字符来渲染 3D 图形,这背后都是数学的功劳。WebGl 其实只是一个光栅化引擎,它非常底层,我们只能用它来画点,线和三角形。

const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl')

这里将 2d 换成 webgl 就可以获取到三维渲染上下文。目前大部分浏览器都支持了 WebGL,不过有的浏览器需要传入 experimental-webgl,比如 IE11。

后面我们会编写在 GPU 中运行的代码(着色器),并且会把数据从 CPU 传递给 GPU。

CPU 和 GPU 设计目标的不同,它们分别针对了两种不同的应用场景。GPU 最初的目的是为了计算机图形和视频游戏。一般我们会在 CPU 中管理整个系统的任务,将一些计算量大,但没什么技术含量,而且要重复很多次的任务交给 GPU 来完成。GPU 拥有数千的内核,可以并发完成大量计算,计算这些任务会比 CPU 快得多。这就是为什么 WebGL 要用到 GPU 的能力,GPU 可以极大提升渲染图片的速度。

OpenGL 介绍

WebGL 基于 OpenGL。OpenGL(Open Graphics Library) 是用于渲染2D、3D矢量图形的跨语言、跨平台的应用程序编程接口,常用于CAD、虚拟现实、科学可视化程序和电子游戏开发。实际的 OpenGL 库通常是显卡生产商根据规范进行开发的。

OpenGL 前身是 SGI 的 IRIS GL API 它在当时被认为是最先进的科技并成为事实上的行业标准,后由 SGI 转变为一项开放标准 OpenGL。1992年 SGI 创建 OpenGL架构审查委员会,2006年将 OpenGL API 标准的控制权交给 Khronos Group。

OpenGL 是跨平台的,在移动设备上一般使用 OpenGL ES(OpenGL for Embedded Systems) 它是 OpenGL 的子集,上图展示了 OpenGL 和 OpenGL ES 的时间线。

WebGL 基于 OpenGL ES 2.0,它是 OpenGL ES 2.0 的子集。WebGL 2.0 基于 OpenGL ES 3.0。大多数现代浏览器都支持了 WebGL 2.0,但是苹果到目前为止还没有支持 WebGL 2.0!所以现在还是大部分应用还是基于 WebGL 1.0 开发。

坐标系

我们知道 2D canvas 中原点在左上角,Y 轴正值向下。

OpenGL 中的坐标系似乎更符合我们的直觉。

原点在中间,Y 正轴向上,X 正轴向右。

注意 OpenGL 中的 X 轴, Y 轴和 Z 轴最大值是 1,最小值是 -1。

const canvasWidth = 500
const x = 100

const two = x / canvasWidth * 2 // 变为 0 -> 2
const clipX = two - 1 // 0 -> 2 变为 -1 -> +1

上面将点的 X 坐标处理到 -1 到 +1 之间,三个点的坐标都处理到这 -1 和 +1 之间,我们就称为标准化设备坐标(Normalized Device Coordinates, NDC),标准化设备坐标是一个 x、y 和 z 值在 -1 到 1 的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。

左右手坐标系

我们上面没有展示 OpenGL 中的 Z 轴张啥样,因为 Z 轴有两种形式,一种是指向屏幕外(正值在屏幕外),另一种是指向屏幕(正值在屏幕内)。

当 Z 轴指向屏幕外时,我们称此坐标系为右手坐标系,当 Z 轴指向屏幕内我们称为左手坐标系。

我们可以伸出左右手来比划下,其中中指指向的就是正 Z 轴。

旋转正方向

左右手坐标系对旋转的正方向正好相反,同样伸出我们的左右手。

左手坐标系用左手,右手坐标系用右手。大拇指朝向轴的正方向,剩下 4 根手指弯曲方向就是旋转正方向。如果我们从轴的正端来看,右手坐标系的正方向是逆时针旋转,左手坐标系的正方向是顺时针旋转。

OpenGL 是哪个坐标系?

那么 OpenGL 是左手坐标系,还是右手坐标系?答案是 都不是

比如我们现在有两个点。

const point1  = [0.5, 0.5, 0.1] // 分别是 X,Y,Z 的值
const point2 = [0.5, 0.5, -0.2]

如果我们在 OpenGL 中画出上面两点,哪个点在前哪个点在后?

这取决于我们渲染两个点的顺序,如果后渲染 point1point1 覆盖 point2,如果后渲染 point2point2 覆盖 point1

深度缓存映射

如果我们开启 OpenGL 的深度测试。

const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl')
gl.enable(gl.DEPTH_TEST) // 激活深度测试

深度测试就是将图形的 Z 值映射存储到深度缓存区中,这样在我们在 OpenGL 中画各种图形时,我们就知道这个图形离我们近还是远,离我们越近的点会覆盖离我们远的点,如果这个点比缓存中的点远时,则抛弃。

当我们开启了深度测试,无论 point1point2 渲染顺序如何,point2 始终会覆盖 point1。也就是 Z 值小的点会覆盖 Z 值大的点,也就是说 OpenGL 是左手坐标系

OpenGL 中还有个 depthRange 函数,它接收两个参数 depthRange(zNear, zFar) 两个参数都是数字,都必须是 01 之间的数字。默认情况下为 depthRange(0, 1),这个函数用来设置深度缓存的范围。

const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl')
gl.depthRange(1, 0) // 反转了默认值
gl.enable(gl.DEPTH_TEST)

如果我们按照上方设置了深度缓存范围,再来渲染 point1point2 我们就发现,无论顺序如何 point1 始终会覆盖 point2 了,OpenGL 变成了右手坐标系

默认情况下深度缓存的范围是 0 到 1。下面我们来看下 OpenGL 是如何将 Z 值([-1, +1]) 变为深度缓存的([0, 1])。

depth = n + (f - n) * (z + 1) / 2 
// n 和 f 是 depthRange 函数设置的。n 是 near,f 是 far。

上面展示了如何将 Z 值变成了深度缓存。

但是

如果真的在 WebGL 中设置 depthRange(1, 0) 你会发现没有任何效果。 这是 WebGL 和 OpenGL 的差异之处,根据 WebGL 1.0 的规范

www.khronos.org/registry/we…

6.12 Viewport Depth Range

The WebGL API does not support depth ranges with where the near plane is mapped to a value greater than that of the far plane. A call to depthRange will generate an INVALID_OPERATION error if zNear is greater than zFar.

也就是在 WebGL 中 depthRangezNear 不允许小于 zFar

要把 WebGL 变成右手坐标系,还有另外一种方法。

gl.clearDepth(0)
gl.depthFunc(gl.GREATER)
gl.clear(gl.DEPTH_BUFFER_BIT)

这里将深度缓存设置成 0(默认值是 1)并用 clear 重置深度缓存。然后设置深度比较函数为大于(默认值是小于),这样就可以让 z 值大的顶点覆盖小的顶点了。

常用坐标系

一般情况下我们也不会使用 depthRangeclearDepth 这些函数。也就是说默认的话 OpenGL 应该是左手坐标系。这里就是让大家非常混乱的地方,实际上开发中都是使用的右手坐标系

当然并不是右手坐标系比左手坐标系好,而是右手坐标系是 OpenGL 的惯例。例如微软的 DirectX 中惯用的是左手坐标系。

Hello World

现在来用 WebGL 来画一个三角形吧。

const canvas = document.createElement('canvas')
canvas.width = 300
canvas.height = 300
document.body.append(canvas) // 创建和将 canvas 加入页面

const gl = canvas.getContext('webgl')
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)
// 告诉 webgl 如何将 0 到 1 坐标 变为屏幕上的坐标

const vertexShader = gl.createShader(gl.VERTEX_SHADER) 
// 创建一个顶点着色器
gl.shaderSource(vertexShader, `
  attribute vec4 a_position;

  void main() {
    gl_Position = a_position; // 设置顶点位置
  }
`) // 编写顶点着色器代码
gl.compileShader(vertexShader) // 编译着色器代码

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) 
// 创建一个片元着色器
gl.shaderSource(fragmentShader, `
  precision mediump float;
  uniform vec4 u_color;

  void main() {
    gl_FragColor = u_color; // 设置片元颜色
  }
`) // 编写片元着色器代码
gl.compileShader(fragmentShader) // 编译着色器代码

const program = gl.createProgram() // 创建一个程序
gl.attachShader(program, vertexShader) // 添加顶点着色器
gl.attachShader(program, fragmentShader) // 添加片元着色器
gl.linkProgram(program) // 连接 program 中的着色器

gl.useProgram(program) // 告诉 webgl 用这个 program 进行渲染

const colorLocation = gl.getUniformLocation(program, 'u_color') 
// 获取 u_color 变量位置
gl.uniform4f(colorLocation, 0.93, 0, 0.56, 1) // 设置它的值

const positionLocation = gl.getAttribLocation(program, 'a_position') 
// 获取 a_position 位置
const positionBuffer = gl.createBuffer() 
// 创建一个顶点缓冲对象,返回其 ID,用来放三角形顶点数据,
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer) 
// 将这个顶点缓冲对象绑定到 gl.ARRAY_BUFFER
// 后续对 gl.ARRAY_BUFFER 的操作都会映射到这个缓存
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
    0, 0.5,
    0.5, 0,
    -0.5, -0.5
]),  // 三角形的三个顶点
     // 因为会将数据发送到 GPU,为了省去数据解析,这里使用 Float32Array 直接传送数据
gl.STATIC_DRAW // 表示缓冲区的内容不会经常更改
)
// 将顶点数据加入的刚刚创建的缓存对象

gl.vertexAttribPointer( // 告诉 OpenGL 如何从 Buffer 中获取数据
    positionLocation, // 顶点属性的索引
    2, // 组成数量,必须是1,2,3或4。我们只提供了 x 和 y
    gl.FLOAT, // 每个元素的数据类型
    false, // 是否归一化到特定的范围,对 FLOAT 类型数据设置无效
    0, // stride 步长 数组中一行长度,0 表示数据是紧密的没有空隙,让OpenGL决定具体步长
    0 // offset 字节偏移量,必须是类型的字节长度的倍数。
)
gl.enableVertexAttribArray(positionLocation);
// 开启 attribute 变量额,使顶点着色器能够访问缓冲区数据

gl.clearColor(0, 1, 1, 1) // 设置清空颜色缓冲时的颜色值
gl.clear(gl.COLOR_BUFFER_BIT) // 清空颜色缓冲区,也就是清空画布

gl.drawArrays( // 从数组中绘制图元
    gl.TRIANGLES, // 画三角形
    0,  // 从哪个点开始画
    3 // 需要用到多少个点
)

用 WebGL 画了个三角形,代码多的确实有点夸张。下面来一点点解释上面这一坨代码到底怎么画出这个三角形的。

顶点和片元着色器

上面代码的注释基本解释了各个步骤是干啥的,不过一些概念还需要详细介绍下。

WebGL 的重点是顶点和片元着色器,也就是上面 gl.shaderSource 第二个参数。

OpenGL 的着色器使用 GLSL(OpenGL Shading Language) 语言进行编写,它有点像 C 语言。

顶点着色器主要是用来确定顶点的位置的,告诉 OpenGL 这个顶点在 NDC(标准化设备坐标) 中的坐标,也就是设置 gl_Position(内置变量) 变量。

片元作色器也叫片段着色器,大家可以理解为像素着色器,一个片元就当成一个像素。片元作色器主要是用来确认这个像素的颜色的,也就是设置给 gl_FragColor(内置变量) 变量。

我们使用 OpenGL 的目的是在屏幕上渲染一张图片。图片是由一个个像素组成的,首先我们定义了一堆顶点给 OpenGL,然后 OpenGL 把每个顶点都传给顶点坐标系,顶点坐标系返回顶点在 NDC 中的位置,然后 OpenGL 将这些坐标进行图形装配(上面我们设置装配成三角形)。然后将图形变成一个个片元(像素),这一步叫做光栅化。然后将这些片元传递给片元着色器,然后片元着色器用来输出这个像素的颜色。

const vertex = [[50, 0], [0, 50], [-50, -50]] // 定义顶点
vertex = vertex.map(v => vertexShader(v)) // 然会 NDC 中的顶点位置(-1 到 +1)
const fragment = rasterization(vertex) // 将这些顶点组成的图形变成一个个片元
const colors = fragment.map(f => fragmentShader(f)) // 将这些片元给片元着色器,确定它的颜色
colors.forEach(color => writePixelToScreen(color)) // 然后渲染到屏幕

上面是简单描述这个过程的伪 js 代码。

又从网上偷了张图

上面图片很好的展示了这个过程,可以忽略几何着色器,WebGL 中只有顶点和片元着色器。

我们从这幅图也可以看出来,片元着色器调用的测试比顶点着色器多得多。所以一些计算能放到顶点着色器就放入到顶点着色器。

向着色器传递数据

着色器是使用 GLSL 写的,那么我们如何在 JS 将数据传入到着色器中呢?

上面 GLSL 代码中有如下两个变量,这代表是从外部传进来的。

// vertex
attribute vec4 a_position;
// frag
uniform vec4 u_color;

这两个变量的类型都是 vec4,可以理解为有 4 个浮点数的数组或 4 个自由度的矢量。大家可以先忽略为什么顶点是 vec4 而不是 vec3

能够从外部传入数据,关键就在 attributeuniform 存储限定字,这两种类型的变量必须要定义在函数外部,并且它们都不能在着色器中被重新赋值。

uniform

我们先来看 uniform。它可以在顶点和片元着色器中使用,它是全局的,在着色器程序中是独一无二的。它有点像 window.u_color,我们在外部JS给它赋值,在顶点和片元着色器中都可以使用,我们也可以在外部JS修改它的值。

const colorLocation = gl.getUniformLocation(program, 'u_color')
gl.uniform4f(colorLocation, 0.93, 0, 0.56, 1)
// 类似于 program.window.u_color = [0.93, 0.0, 0.56, 1.0];

我们首先获取 u_color 在着色器中的位置,然后使用 uniform4f 传递数据,4f 代表是 4 个浮点数,也就是 rgba,需要注意 OpenGL 中颜色值的范围不是 0 到 255,而是 0 到 1。

attribute

attribute 只能用在顶点着色器,被用来表示逐顶点信息,上面例子中,我们定义了三个顶点传递给 a_position 变量,顶点着色器不是一次性获取到这些顶点,而是一个个的获取。

const points = [p1, p2, p3]
points.forEach(p => vertexShader(p))

类似上面这种执行顶点着色器,当然在显卡中会并发的执行顶点着色器。我们使用 JS 传递 attribute 比较麻烦一点。

const positionLocation = gl.getAttribLocation(program, 'a_position') 
const positionBuffer = gl.createBuffer() 
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer) 
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
    0, 0.5,
    0.5, 0,
    -0.5, -0.5
]), gl.STATIC_DRAW)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(positionLocation);

uniform 一样我们首先获取变量的地址,然后创建一个顶点缓冲来存储顶点数据,顶点缓冲对象的缓冲类型是 gl.ARRAY_BUFFER,需要将 buffer 绑定到 gl.ARRAY_BUFFER,后续对 gl.ARRAY_BUFFER 操作就相当于对这个 buffer 进行操作。然后我们使用 bufferData 方法将数据存入缓存中,加入缓存区后,我们还需要使用 vertexAttribPointer 告诉 OpenGL 如何获取数据,最后使用 enableVertexAttribArray 启用顶点属性就行了。

代码解析

了解了顶点和片元着色器,基本上上面的代码就理解的差不多了,现在让我们再过一边上面的代码。

要使用 WebGL 渲染,首先需要获取渲染上下文,这里只需要将平时用的 2d 参数改为 webgl 就行,然后设置 WebGL viewport,这样 OpenGL 就可以根据它将 NDC 坐标变成屏幕上的坐标。

接着我们创建了顶点和片元着色器,然后编译着色器代码。创建一个着色器程序,将顶点和片元着色器加入到这个着色器程序并连接着色器,然后告诉 webgl 使用这个着色器程序。

接着就是上面说过的向着色器中传递数据,接下来我们设置了 WebGL 的默认颜色缓冲区颜色值,然后清空颜色缓冲区,也就是使用我们设置的颜色清除画布。

最后一步我们使用 gl.drawArrays 开始渲染了,我们选择渲染三角形,当然还可以把类型变成线段,最后就是三条线的三角形,而不是填充的三角形,我们有顶点缓冲区中有三个顶点,所以这里设置了渲染 3 次。

OpenGL 本身就是一个状态机,我们使用 API 设置它的状态,来告诉它如何运行,OpenGL 的状态通常被称为 OpenGL 上下文。

GLSL ES 入门

可能大家对 GLSL 比较陌生,下面将详细介绍下这个 OpenGL 着色器语言。在 OpenGL ES 和 WebGL 中使用的是 GLSL ES,可能大家已经猜到了,WebGL 中使用是基于 GLSL 1.2 也是 GLSL ES 2.0 版本,WebGL2 中使用的是基于 3.30 的版本,也是 GLSL ES 的 3.0 版本。

它是强类型语言,每一句都需要有分号。它注释语法和 JS 一样,变量名规则也和 JS 一样,不能使用关键字,保留字,不能以 gl_webgl__webgl_ 开头。

GLSL 中主要有三种数据值类型,浮点数、整数和布尔。注意浮点数必须要带小数点。类型转换可以直接使用 floatintbool 函数。

float f = float(1);

它的运算符基本也和 JS 一样,++ -- += && || 还有三元运算符都支持。

矩阵和矢量

因为是用来画图的,所以对矩阵和矢量也有支持。

vec2vec3vec3 代表 2、3 和 4 个浮点数的矢量。

ivec2ivec3ivec3 整数版本。

bvec2bvec3bvec3 布尔版本。

mat2mat3mat4 2x2、3x3 和 4x4 的矩阵。

vec3 color = vec3(1., 1., 1.); // 白色

GLSL 对矢量的赋值、获取和构造也十分强大。

vec4 v4 = vec4(1.,2.,3.,4.);
vec3 color = v4.rgb; // 可是用 rgba 获取,相当于 vec3(1., 2., 3.)
vec3 position = v4.xyz; // 也可以用 xyzw 获取
vec3 texture = v4.stp; // 也可以使用 stpq 获取

vec2 v2 = vec2(v4); // 使用 v4 的前两个元素构造
vec4 v41 = vec4(v2, v4.yw); // 使用 v2 和 v4 的后两个元素构造
vec4 v42 = vec4(1.); // 4 个元素都设成 1.
v42.g = 2.;
v42[1] = 3.; // 也可以使用 [] 获取

mat2 m2 = mat2(1., 2., 3., 4.);
vec2 v21 = m2[0]; // [1., 2.]
float f = v21[0].x // 混合使用都行

分支和循环

分支和循环也和 JS 一样。

if (true) {} else if (true) {} else {}
for (int i = 0; i < 3; i++) {
	continue; // 或 break
}

函数

每个着色器中都必须有个 main 函数,它会被自动执行,函数的返回值写在函数名前,没返回值就为 void

float add(float a, float b) {
	return a + b;
}

如果函数定义在调用之后则需要先声明该函数。

float add(float a, float b); // 声明
void main() {
	float c = add(1., 1.);
}
float add(float a, float b) {
	return a + b;
}

另外函数参数还有限定词。

in 默认,表示像函数传入参数。

const inin 一样,但是不能修改。

out 在函数中被赋值,并被传出。

inout 传入参数,在函数中被赋值,并被传出。

void add(in float a, in float b, out float answer) {
	answer = a + b; // 不使用 return 而用 out
}

GLSL 中还有一些内置函数,例如 sin, cos, pow, abs 等等。

精度限定字

精度限定字用来控制数值的精度,越高的精度也就意味着更慢的性能,所以我们要合理的控制程序的精度。GLSL 中分为三种精度 highpmediumplowp,分别是高、中和低精度。

mediump float size; // 声明一个中精度浮点数
highp int len; // 声明一个高精度整数
lowp vec4 v; // 低精度矢量

这样一个一个变量的声明,非常麻烦,我们还可以一次性声明这些精度。

precision mediump float; // 浮点数全部使用中精度

GLSL 已经帮我们设置了默认变量精度。

在顶点着色器中 intfloat 都是 highp

在片元着色器中 intmediumpfloat 没有定义。

这也就是为什么上面片元着色器中第一行代码是 precision mediump float; 了,因为 OpenGL 没有设置默认值,所以必须得我们自己设置。

另外在顶点和片元着色器 sampler2DsamplerCube 都是 lowp(它们主要用来渲染图片,后面会详细讲解)。

更多关于 GLSL 内容,可以查看 OpenGL ES Reference Pages

立方体

我们现在来研究下如何渲染一个立方体吧。如前所述,WebGL 是很底层的 API,它只能用来画点、线和三角形,那么我们如何来画正方形呢?

其实大家看到的那些精美的 3D 模型,其实都是一个个非常小的三角形组成的。

比如这个冰箱就是由 3 万多个三角形组成。为什么选择三角形呢?这是因为任何多边形都可以最终分解为多个三角形,也就是说三角形是多边形的基本单位,并且三角形一定在一个平面上。

可以使用两个三角形组合来表示一个正方形,立方体有 6 个面,也就需要 12 个三角形,每个三角形需要 3 个顶点,那么最终我们就需要 36 个顶点!

但是立方体比较特殊,它其实只有 8 个顶点,一个顶点被三个面共用。那么有什么方法让我们只用定义 8 个顶点呢?OpenGL 还可以通过我们定义的顶点索引来渲染三角形,比如我们发送 8 个顶点和一个顶点索引数组到 GPU,然后 OpenGL 就可以使用索引数组的顺序来渲染三角形了。

比如索引数组 [1,2,3,3,2,0] 并且我们是画三角形的话,这就表示使用顶点数组下标为 123 的顶点来渲染一个三角形,然后用 320 下标渲染另一个三角形。

const canvas = document.createElement('canvas')
canvas.width = canvas.height = 300
document.body.appendChild(canvas)
const gl = canvas.getContext('webgl')
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)

const program = createProgramFromSource(gl, `
attribute vec4 aPos;
attribute vec4 aColor;
varying vec4 vColor;

void main() {
  gl_Position = aPos;
  vColor = aColor;
}
`, `
precision mediump float;
varying vec4 vColor;

void main() {
  gl_FragColor = vColor;
}
`)

const points = new Float32Array([
  -0.5,0.5,-0.5, 0.5,0.5,-0.5, 0.5,-0.5,-0.5, -0.5,-0.5,-0.5,
  0.5,0.5,0.5, -0.5,0.5,0.5, -0.5,-0.5,0.5, 0.5,-0.5,0.5
])
const colors = new Float32Array([
  1,0,0, 0,1,0, 0,0,1, 1,0,1,
  0,0,0, 0,0,0, 0,0,0, 0,0,0
])
const indices = new Uint8Array([
  0, 1, 2, 0, 2, 3, // 前
  1, 4, 2, 4, 7, 2, // 右
  4, 5, 6, 4, 6, 7, // 后
  5, 3, 6, 5, 0, 3, // 左
  0, 5, 4, 0, 4, 1, // 上
  7, 6, 3, 7, 3, 2  // 下
])

const [posLoc, posBuffer] = createAttrBuffer(gl, program, 'aPos', points)
const [colorLoc, colorBuffer] = createAttrBuffer(gl, program, 'aColor', colors)
const indexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW)

gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer)
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(posLoc)

gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer)
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(colorLoc)

gl.enable(gl.DEPTH_TEST)
gl.clearColor(0, 1, 1, 1)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

gl.drawElements(
  gl.TRIANGLES, // 要渲染的图元类型
  indices.length, // 要渲染的元素数量
  gl.UNSIGNED_BYTE, // 元素数组缓冲区中的值的类型
  0 // 元素数组缓冲区中的偏移量, 字节单位
)

function createShader(gl, type, source) {
  const shader = gl.createShader(type)
  gl.shaderSource(shader, source)
  gl.compileShader(shader)
  return shader;
}

function createProgramFromSource(gl, vertex, fragment) {
  const vertexShader = createShader(gl, gl.VERTEX_SHADER,vertex)
  const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragment)
  const program = gl.createProgram()
  gl.attachShader(program, vertexShader)
  gl.attachShader(program, fragmentShader)
  gl.linkProgram(program)
  gl.useProgram(program)
  return program
}

function createAttrBuffer(gl, program, attr, data) {
  const location = gl.getAttribLocation(program, attr)
  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
  gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
  return [location, buffer]
}

上面代码画了一个边长是 1 的立方体,立方体的正中心就在坐标轴原点。

我们除了定义每个顶点的坐标,还定义了每个顶点的颜色,靠近屏幕外的 4 个顶点设置成彩色,后 4 个顶点设置成黑色。

然后使用 Uint8Array 定义了顶点索引(如果又索引值大于 256 就应该使用 Uint16Array)。

颜色数据和坐标一样,创建一个缓存然后,告诉 WebGL 如何获取获取。但是顶点索引数据有一点点不同,它的绑定点不是 gl.ARRAY_BUFFER 而是 gl.ELEMENT_ARRAY_BUFFER 它是用于元素索引的 Buffer。

这里还开启了深度测试,这样后画的三角形就不会覆盖先画的,而是根据它们的 Z 值判断。另外清理的时候不用调用两次 clear 函数,而是使用 | 运算符,gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT

最后一步将 drawArrays 换成 drawElements,表示我们用索引来渲染图形。

存储限定字 varying

存储限定字其实一共有三个 attributeuniformvarying。上面已经介绍了前两个,它们都是从外部 JS 获取数据。

varying 是顶点着色器向片元着色器传送数据。上面例子中我们将 aColor 赋值给 vColor,然后在片元着色器中就可以使用 vColor 了。

varying 也是有原因的,我们可以先来看看上面代码最终渲染成什么样子。

我们设置前面 4 个顶点颜色分别是红、绿、蓝和粉色,怎么渲染出来的是一种渐变色?

前面将过,片段着色器执行的次数一般比顶点着色器执行次数多得多。这是因为在片元着色器之前会执行光栅化,会将图元离散化,变成一个个像素,然后每个像素都会执行片元着色器,来确定这个像素的颜色。

varying 变量从顶点着色器向片元着色器传递时会被 OpenGL 插值,也就是我们定义了三角形 3 个顶点的颜色,三角形内部的像素都是根据这 3 个顶点颜色插值出来的。比如一个线段一个端点是红色,另一个是绿色,那么这个线段中间就是 50% 的红色和 50% 的绿色。

旋转和透视

我们渲染的是一个立方体,为什么显示出来确实一个正方形?

因为这个立方体的正面正对着我们,我们就只能看见它的正面,如果我们将这个立方体稍微旋转一下,就可以看出来这个是立方体了。

现实生活中,我们看物体会有近大远小的效果,也就是有透视效果。在 3D 图形中也应该也有类似的效果,现在我们渲染的这个立方体是没有透视效果的,也就是前面那个面会和后面那个面一样大。

如何让图形旋转,让它看起来有透视效果需要将在下篇文章中介绍。

总结

这篇文章讲了 WebGL 基础知识和一些重要概念。WebGL 的 X、Y 和 Z 轴坐标范围是 -1 到 +1,任何超出这个范围的顶点都会被裁切,这个坐标我们称为标准化设备坐标(NDC)。WebGL 默认是左手坐标系,但是我们也可以将它变成右手坐标系。一般我们会选择一个坐标系就不会再改变,WebGL 的惯例是右手坐标系。渲染图形时先对每个顶点执行顶点着色器,然后再进行光栅化,其中 varying 变量会被插值,然后执行片元着色器,返回各个像素的颜色。最后我们渲染一个立方体看起来像个正方形,因为我们看的是它的正对面,我们需要旋转它才能看见其他的面,WebGL 中并没有 API 让我们调用一下,立方体就旋转了,我们需要用数学公式来旋转,通常是使用旋转矩阵来完成,下篇文章将详细旋转、缩放等变换。

参考