5

webgl.jpg

最近由于工作需要,开始学习 WebGL 相关的知识。这篇文章的目的就是记录下学习过程中的一些知识概念,并实现一个简单的 demo,帮助大家快速理解 webgl 的概貌并上手开发。最后会分享自己对于 webgl 的几点想法,给有需要的人提供参考。

WebGL 全称 Web Graphics Library,是一种支持 3D 的绘图技术,为 web 开发者提供了一套 3D 图形相关的接口。通过这些接口,开发者可以直接跟 GPU 进行通信。

WebGL 程序分为 2 部分:

  • 使用 Javascript 编写的运行在 CPU 的程序
  • 使用 GLSL 编写的运行在 GPU 的着色器程序

着色器程序接收 CPU 传过来的数据,并进行一定处理,最终渲染成丰富多彩的应用样式。

渲染流程

WebGL 能绘制的基本图元只有 3 种,分别是线段三角形,对应了物理世界中的点线面。所有复杂的图形或者立方体,都是先用组成基本结构,然后用三角形将这些点构成的平面填充起来,最后由多个平面组成立方体。

所以,我们需要从构建顶点数据开始。顶点坐标一般还需要经过一些转换步骤,才能够变成符合裁剪坐标系的数据。这些转换步骤,我们可以用矩阵来表示。把变换矩阵和初始顶点信息传给 GPU,大致处理步骤如下:

  1. 顶点着色器:根据变换矩阵和初始顶点信息进行运算,得到裁剪坐标。这个计算过程也可以放到 js 程序中做,但是这样就不能充分利用 GPU 的并行计算优势了。
  2. 图元装配:使用三角形图元装配顶点区域。
  3. 光栅化:用没有颜色的像素填充图形区域。
  4. 片元着色器:为像素着色。

我们可以用 GLSL 编程控制的是顶点着色器片元着色器这 2 步。这一套类似于流水线的渲染过程,在业界被称为渲染管线

process
(图片来自掘金小册:WebGL 入门与实践,这是一份不错的入门学习资料,推荐一下)

开始创作

这一部分,我会带领大家一步一步创建一个会旋转的正方体,帮助大家上手 webgl 开发。

准备工作

WebGL 开发的准备工作类似于 canvas 开发,需要准备一个 html 文档,并包含<canvas>标签,只不过调用getContext时传入的参数不是2d,而是webgl

另外,webgl 还需要使用 GLSL 进行顶点着色器和片元着色器编程。

下面是准备好的 html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <canvas id="canvas"></canvas>
  <script>
    // 顶点着色器代码
    const vertexShaderSource = `
      // 编写 glsl 代码
    `;
    // 片元着色器代码
    const fragmentShaderSource = `
      // 编写 glsl 代码
    `;
    
    // 根据源代码创建着色器对象
    function createShader(gl, type, source) {
      const shader = gl.createShader(type);
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      return shader;
    }
    
    // 获取 canvas 并设置尺寸
    const canvas = document.querySelector('#canvas');
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    // 获取 webgl 上下文
    const gl = canvas.getContext('webgl');
    
    // 创建顶点着色器对象
    const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
    // 创建片元着色器对象
    const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
    // 创建 webgl 程序对象
    const program = gl.createProgram();
    // 绑定顶点着色器
    gl.attachShader(program, vertexShader);
    // 绑定片元着色器
    gl.attachShader(program, fragmentShader);
    // 链接程序
    gl.linkProgram(program);
    // 使用程序
    gl.useProgram(program);
  </script>
</body>
</html>

几乎每一行代码都加了注释,应该能看懂了。这里再单独说一下着色器源代码,上面的示例中,我们预留了一个字符串模板,用于编写着色器的 GLSL 代码。实际上,只要在创建着色器对象的时候,能把着色器代码作为字符串传入createShader方法就行,不管是直接从 js 变量中获取,还是通过 ajax 从远端获取。

目前为止,我们已经开始调用了 webgl 相关的 js api(各 api 具体用法请翻阅MDN),但是这些代码还不能渲染出任何画面。

这部分我们尝试渲染一个固定位置的点。先从顶点着色器开始:

void main() {
  gl_PointSize = 5.0;
  gl_Position = vec4(0, 0, 0, 1);
}

这部分是 GLSL 代码,类似于 C 语言,解释下含义:

  • 要执行的代码包裹在main函数中
  • gl_PointSize 表示点的尺寸
  • gl_Position是全局变量,用于定义顶点的坐标,vec4表示一个四位向量,前三位是x/y/z轴数值,取值区间均为0-1,最后一位是齐次分量,是 GPU 用来从裁剪坐标系转换到NDC坐标系的,我们设置为 1 就行。

接着写片元着色器:

void main() {
  gl_FragColor = vec4(1, 0, 0, 1);
}

gl_FragColor表示要为像素填充的颜色,后面的四维向量类似于 CSS 中的rgba,只不过rgb的值从0-255等比缩放为0-1,最后一位代表不透明度。

最后,我们来完善一下 js 代码:

// 设置清空 canvas 画布的颜色
gl.clearColor(1, 1, 1, 1);
// 清空画布
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制一个点
gl.drawArrays(gl.POINTS, 0, 1);

clearColor设置为1,1,1,1,相当于rgba(255, 255, 255, 1),也就是白色。渲染后效果如下:

dot

三角形

成功渲染一个点之后,我们已经对于 webgl 的渲染流程有一定了解。三角形也是 webgl 基本图元之一,要渲染三角形,我们可以指定三角形 3 个顶点的坐标,然后指定绘制类型为三角形。

之前的示例只渲染一个顶点,用gl_Position接受一个顶点的坐标,那么如何指定 3 个顶点坐标呢?这里我们需要引入缓冲区的机制,在 js 中指定 3 个顶点的坐标,然后通过缓冲区传递给 webgl。

先改造下顶点着色器:

// 设置浮点数精度
precision mediump float;
// 接受 js 传过来的坐标
attribute vec2 a_Position;
void main() {
  gl_Position = vec4(a_Position, 0, 1);
}

attribute可以声明在顶点着色器中,js 可以向attribute传递数据。这里我们声明了一个二维向量a_Position,用来表示点的x/y坐标,z轴统一为 0。

另外,我们把gl_PointSize的赋值去掉了,因为我们这次要渲染的是三角形,不是点。

片元着色器暂时不需要改动。

接着我们改造下 js 部分。

const points = [
  -0.5, 0, // 第 1 个顶点
  0.5, 0, // 第 2 个顶点
  0, 0.5 // 第 3 个顶点
];

// 创建 buffer
const buffer = gl.createBuffer();
// 绑定buffer为当前缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 获取程序中的 a_Position 变量
const a_Position = gl.getAttribLocation(program, 'a_Position');
// 激活 a_Position
gl.enableVertexAttribArray(a_Position);
// 指定 a_Position 从 buffer 获取数据的方式
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
// 给 buffer 灌数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.STATIC_DRAW);
// 设置清空 canvas 画布的颜色
gl.clearColor(1, 1, 1, 1);
// 清空画布
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制三角形
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);

这样,一个三角形就绘制出来了,看到的效果应该是这样:

triangle

正方形

正方形并不是 WebGL 的基本图元之一,我们要如何绘制呢?答案就是用 2 个三角形拼接。在上面绘制三角形的代码基础上改动就很容易了,把 3 个顶点改为 6 个顶点,表示 2 个三角形就行

const points = [
  -0.2, 0.2, // p1
  -0.2, -0.2, // p2
  0.2, -0.2, // p3
  0.2, -0.2, // p4
  0.2, 0.2, // p5
  -0.2, 0.2 // p6
];

效果如下:
rect

可以看到,p3 和 p4,p1 和 p6,其实是重合的。这里可以使用索引来减少重复点的声明。我们再次改造下 js 代码

const points = [
  -0.2, 0.2, // p1
  -0.2, -0.2, // p2
  0.2, -0.2, // p3
  0.2, 0.2, // p4
];

// 根据 points 中的 index 设置索引
const indices = [
  0, 1, 2, // 第一个三角形
  2, 3, 0 // 第二个三角形
];

// 创建 buffer
const buffer = gl.createBuffer();
// 绑定buffer为当前缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 获取程序中的 a_Position 变量
const a_Position = gl.getAttribLocation(program, 'a_Position');
// 激活 a_Position
gl.enableVertexAttribArray(a_Position);
// 指定 a_Position 从 buffer 获取数据的方式
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
// 给 buffer 灌数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.STATIC_DRAW);

// 创建索引 buffer
const indicesBuffer = gl.createBuffer();
// 绑定索引 buffer
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indicesBuffer);
// 灌数据
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);

// 设置清空 canvas 画布的颜色
gl.clearColor(1, 1, 1, 1);
// 清空画布
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制三角形
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);

效果与之前用 6 个点一样。千万别小看这里 2 个点的优化,在大型项目中,复杂图形往往由成千上万个点构成,使用索引能对内存占用进行有效的优化。

正方体

正方体由 6 个正方形组成,共 8 个顶点,我们只要构建出这 8 个顶点的位置,然后用三角形图元把它绘制出来就行了。为了加以区分各个平面,我们使用不同的颜色的来绘制每个面。

先改动下顶点着色器,用来接收顶点的颜色信息。

// 设置浮点数精度
precision mediump float;
// 接受 js 传过来的坐标
attribute vec3 a_Position;
// 接收 js 传过来的颜色
attribute vec4 a_Color;
// 透传给片元着色器
varying vec4 v_Color;
void main() {
  gl_Position = vec4(a_Position, 1);
  v_Color = a_Color;
}

这里新增了 2 个变量,a_Color类似于a_Position,可以接收 js 传过来的顶点颜色信息,但是颜色最终是在片元着色器中使用的,所以我们要通过v_Color透传出去。varying类型变量就是用于在顶点着色器和片元着色器之间传递数据。另外,a_Position我们改为了三维向量,因为需要制定 z 坐标。

接下来是片元着色器:

// 设置浮点数精度
precision mediump float;
// 接收顶点着色器传来的颜色信息
varying vec4 v_Color;
void main() {
  gl_FragColor = v_Color / vec4(255, 255, 255, 1);
}

除了接收v_Color之外,我们还把v_Color进行了处理,这样在 js 中我们就可以使用最原始的rgba值,然后在 GPU 中计算得到真正的gl_FragColor,充分利用了 GPU 的并行计算优势。

现在,我们可以在 js 中构建正方体的顶点信息了。

/**
 * 创建一个立方体,返回 points,indices,colors
 * 
 * @params width 宽度
 * @params height 高度
 * @params depth 深度
 */
function createCube(width, height, depth) {
  const baseX = width / 2;
  const baseY = height / 2;
  const baseZ = depth / 2;

  /*
        7 ---------- 6
       /|          / |
      / |         /  | 
    3 --|-------- 2  |
    |   4 --------|- 5
    |  /          |  /
    | /           | / 
    |/            |/ 
    0 ----------- 1
  */
  const facePoints = [
    [-baseX, -baseY, baseZ], // 顶点0
    [baseX, -baseY, baseZ], // 顶点1
    [baseX, baseY, baseZ], // 顶点2
    [-baseX, baseY, baseZ], // 顶点3
    [-baseX, -baseY, -baseZ], // 顶点4
    [baseX, -baseY, -baseZ], // 顶点5
    [baseX, baseY, -baseZ], // 顶点6
    [-baseX, baseY, -baseZ], // 顶点7
  ];
  const faceColors = [
    [255, 0, 0, 1], // 前面
    [0, 255, 0, 1], // 后面
    [0, 0, 255, 1], // 左面
    [255, 255, 0, 1], // 右面
    [0, 255, 255, 1], // 上面
    [255, 0, 255, 1] // 下面
  ];
  const faceIndices = [
    [0, 1, 2, 3], // 前面
    [4, 5, 6, 7], // 后面
    [0, 3, 7, 4], // 左面
    [1, 5, 6, 2], // 右面
    [3, 2, 6, 7], // 上面
    [0, 1, 5, 4], // 下面
  ];

  let points = [];
  let colors = [];
  let indices = [];

  for (let i = 0; i < 6; i++) {
    const currentFaceIndices = faceIndices[i];
    const currentFaceColor = faceColors[i];
    for (let j = 0; j < 4; j++) {
      const pointIndice = currentFaceIndices[j];
      points = points.concat(facePoints[pointIndice]);
      colors = colors.concat(currentFaceColor);
    }
    const offset = 4 * i;
    indices.push(offset, offset + 1, offset + 2);
    indices.push(offset, offset + 2, offset + 3);
  }

  return {
    points, colors, indices
  };
}

const { points, colors, indices } = createCube(0.6, 0.6, 0.6);

// 下面与绘制正方形基本一致,仅需增加 colors 的传递逻辑即可

这样绘制出来的图形效果是:

rect1

这里有 2 个问题:

  • Q:为什么是长方形,而不是正方体?

    • A:GPU 拿到赋值给gl_Position的值之后,除了把裁剪坐标转换成NDC坐标之外,还会根据画布的宽高进行一次视口变换,画布的宽高比不同,渲染出来的效果就不同。要解决这个问题,需要使用投影变换对坐标先处理一道。经过投影变换,我们再任何尺寸的画布上看到的都会是一个正方形,也就是正方体的一个面,这时候我们再让正方体旋转起来,就可以看到它的所有面了。
  • Q:根据设置的颜色,前面对应的色值是rgba(255, 0, 0, 1),也就是红色,为什么看到的是绿色?

    • A:裁剪坐标系遵循左手坐标系,也就是 z 轴正方向是指向屏幕里面,所以这里我们看到的其实是正方体的后面,就是rgba(0, 255, 0, 1)绿色了。

接下来我们增加一些矩阵计算工具,用于计算正交投影旋转等效果对应的坐标。

先修改下顶点着色器,增加一个变量用于引入坐标转换矩阵

// 设置浮点数精度
precision mediump float;
// 接受 js 传过来的坐标
attribute vec3 a_Position;
// 接收 js 传过来的颜色
attribute vec4 a_Color;
// 透传给片元着色器
varying vec4 v_Color;
// 转换矩阵
uniform mat4 u_Matrix;
void main() {
  gl_Position = u_Matrix * vec4(a_Position, 1);
  v_Color = a_Color;
}

矩阵计算工具我们直接引入别人写好的:

<script src="matrix.js"></script>
<script>
  // 前面的代码都一样,我就不重复贴了
  // 增加如下计算矩阵代码
  const aspect = canvas.width / canvas.height;
  const projectionMatrix = matrix.ortho(-aspect * 4, aspect * 4, -4, 4, 100, -100);
  const dstMatrix = matrix.identity();
  const tmpMatrix = matrix.identity();
  let xAngle = 0;
  let yAngle = 0;
  
  function deg2radians(deg) {
      return Math.PI / 180 * deg;
    }
  
  gl.clearColor(1, 1, 1, 1);
  const u_Matrix = gl.getUniformLocation(program, 'u_Matrix');
  
  function render() {
    xAngle += 1;
    yAngle += 1;
    // 先绕 Y 轴旋转矩阵
    matrix.rotationY(deg2radians(yAngle), dstMatrix);
    // 再绕 X 轴旋转
    matrix.multiply(dstMatrix, matrix.rotationX(deg2radians(xAngle), tmpMatrix), dstMatrix);
    // 模型投影矩阵
    matrix.multiply(projectionMatrix, dstMatrix, dstMatrix);
    // 给 GPU 传递矩阵
    gl.uniformMatrix4fv(u_Matrix, false, dstMatrix);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);
    // 让立方体动起来
    requestAnimationFrame(render);
  }
  
  render();
</script>

效果如下:

ball

完整代码请参考这里

谈谈感受

  • 原生的 webgl 编程还是比较繁琐,好在业内已经有一些优秀的库可以直接用,比如Three.jsBabylon之类。如果只是为了完成效果,直接引入库就可以了。但是如果是为了深入学习 webgl 开发,还是要了解原生的写法。
  • 要想学好 webgl 开发,只知道 api 和调用流程是不行的,还需要学习计算机图形学、线性代数等,明白各个几何变换如何用矩阵表示出来。例如上面的正交投影变换,应该有很多人没看懂。
  • WebGL 是基于 OpenGL 的,但是现在又出现了一个 Vulkan,大有取代 OpenGL 的意思。我觉得大可不必惊慌,还是要打好自己的基础,不管编程语言如何变化,计算机图形学、线性代数等基础学科知识不会变。
  • WebGL 开发目前在前端领域还属于偏小众的技术,但是随着 5G 的发展,未来应用的交互形式还会不断进化,3d 迟早会变为常态,到那时 3d 效果开发应该会成为前端的必备技能。

JoeRay61
2.3k 声望182 粉丝