WebGL 初印象

webgl.jpg

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

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

WebGL 程序分为 2 部分:前端

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

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

渲染流程

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

因此,咱们须要从构建顶点数据开始。顶点坐标通常还须要通过一些转换步骤,才可以变成符合裁剪坐标系的数据。这些转换步骤,咱们能够用矩阵来表示。把变换矩阵和初始顶点信息传给 GPU,大体处理步骤以下:github

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

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

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

开始创做

这一部分,我会带领你们一步一步建立一个会旋转的正方体,帮助你们上手 webgl 开发。编程

准备工做

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

另外,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 效果开发应该会成为前端的必备技能。
相关文章
相关标签/搜索