最近由于工作需要,开始学习 WebGL 相关的知识。这篇文章的目的就是记录下学习过程中的一些知识概念,并实现一个简单的 demo,帮助大家快速理解 webgl 的概貌并上手开发。最后会分享自己对于 webgl 的几点想法,给有需要的人提供参考。
WebGL 全称 Web Graphics Library,是一种支持 3D 的绘图技术,为 web 开发者提供了一套 3D 图形相关的接口。通过这些接口,开发者可以直接跟 GPU 进行通信。
WebGL 程序分为 2 部分:
- 使用 Javascript 编写的运行在 CPU 的程序
- 使用 GLSL 编写的运行在 GPU 的着色器程序
着色器程序接收 CPU 传过来的数据,并进行一定处理,最终渲染成丰富多彩的应用样式。
渲染流程
WebGL 能绘制的基本图元只有 3 种,分别是点
、线段
、三角形
,对应了物理世界中的点线面。所有复杂的图形或者立方体,都是先用点
组成基本结构,然后用三角形
将这些点构成的平面填充起来,最后由多个平面组成立方体。
所以,我们需要从构建顶点数据开始。顶点坐标一般还需要经过一些转换步骤,才能够变成符合裁剪坐标系
的数据。这些转换步骤,我们可以用矩阵来表示。把变换矩阵和初始顶点信息传给 GPU,大致处理步骤如下:
- 顶点着色器:根据变换矩阵和初始顶点信息进行运算,得到裁剪坐标。这个计算过程也可以放到 js 程序中做,但是这样就不能充分利用 GPU 的并行计算优势了。
- 图元装配:使用三角形图元装配顶点区域。
- 光栅化:用没有颜色的像素填充图形区域。
- 片元着色器:为像素着色。
我们可以用 GLSL 编程控制的是顶点着色器
和片元着色器
这 2 步。这一套类似于流水线的渲染过程,在业界被称为渲染管线
。
(图片来自掘金小册: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)
,也就是白色。渲染后效果如下:
三角形
成功渲染一个点之后,我们已经对于 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);
这样,一个三角形就绘制出来了,看到的效果应该是这样:
正方形
正方形并不是 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
];
效果如下:
可以看到,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 的传递逻辑即可
这样绘制出来的图形效果是:
这里有 2 个问题:
-
Q:为什么是长方形,而不是正方体?
- A:GPU 拿到赋值给
gl_Position
的值之后,除了把裁剪坐标
转换成NDC
坐标之外,还会根据画布的宽高进行一次视口变换
,画布的宽高比不同,渲染出来的效果就不同。要解决这个问题,需要使用投影变换
对坐标先处理一道。经过投影变换
,我们再任何尺寸的画布上看到的都会是一个正方形,也就是正方体的一个面,这时候我们再让正方体旋转起来,就可以看到它的所有面了。
- A:GPU 拿到赋值给
-
Q:根据设置的颜色,
前面
对应的色值是rgba(255, 0, 0, 1)
,也就是红色,为什么看到的是绿色?- A:裁剪坐标系遵循
左手坐标系
,也就是 z 轴正方向是指向屏幕里面,所以这里我们看到的其实是正方体的后面
,就是rgba(0, 255, 0, 1)
绿色了。
- A:裁剪坐标系遵循
接下来我们增加一些矩阵计算工具,用于计算正交投影
、旋转
等效果对应的坐标。
先修改下顶点着色器,增加一个变量用于引入坐标转换矩阵
// 设置浮点数精度
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>
效果如下:
完整代码请参考这里
谈谈感受
- 原生的 webgl 编程还是比较繁琐,好在业内已经有一些优秀的库可以直接用,比如
Three.js
、Babylon
之类。如果只是为了完成效果,直接引入库就可以了。但是如果是为了深入学习 webgl 开发,还是要了解原生的写法。 - 要想学好 webgl 开发,只知道 api 和调用流程是不行的,还需要学习计算机图形学、线性代数等,明白各个几何变换如何用矩阵表示出来。例如上面的正交投影变换,应该有很多人没看懂。
- WebGL 是基于 OpenGL 的,但是现在又出现了一个 Vulkan,大有取代 OpenGL 的意思。我觉得大可不必惊慌,还是要打好自己的基础,不管编程语言如何变化,计算机图形学、线性代数等基础学科知识不会变。
- WebGL 开发目前在前端领域还属于偏小众的技术,但是随着 5G 的发展,未来应用的交互形式还会不断进化,3d 迟早会变为常态,到那时 3d 效果开发应该会成为前端的必备技能。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。