最近因为工做须要,开始学习 WebGL 相关的知识。这篇文章的目的就是记录下学习过程当中的一些知识概念,并实现一个简单的 demo,帮助你们快速理解 webgl 的概貌并上手开发。最后会分享本身对于 webgl 的几点想法,给有须要的人提供参考。javascript
WebGL 全称 Web Graphics Library,是一种支持 3D 的绘图技术,为 web 开发者提供了一套 3D 图形相关的接口。经过这些接口,开发者能够直接跟 GPU 进行通讯。html
WebGL 程序分为 2 部分:前端
着色器程序接收 CPU 传过来的数据,并进行必定处理,最终渲染成丰富多彩的应用样式。java
WebGL 能绘制的基本图元只有 3 种,分别是点
、线段
、三角形
,对应了物理世界中的点线面。全部复杂的图形或者立方体,都是先用点
组成基本结构,而后用三角形
将这些点构成的平面填充起来,最后由多个平面组成立方体。git
因此,咱们须要从构建顶点数据开始。顶点坐标通常还须要通过一些转换步骤,才可以变成符合裁剪坐标系
的数据。这些转换步骤,咱们能够用矩阵来表示。把变换矩阵和初始顶点信息传给 GPU,大体处理步骤以下:github
咱们能够用 GLSL 编程控制的是顶点着色器
和片元着色器
这 2 步。这一套相似于流水线的渲染过程,在业界被称为渲染管线
。web
(图片来自掘金小册:WebGL 入门与实践,这是一份不错的入门学习资料,推荐一下)ajax
这一部分,我会带领你们一步一步建立一个会旋转的正方体,帮助你们上手 webgl 开发。编程
WebGL 开发的准备工做相似于 canvas 开发,须要准备一个 html 文档,并包含<canvas>
标签,只不过调用getContext
时传入的参数不是2d
,而是webgl
。canvas
另外,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_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:为何是长方形,而不是正方体?
gl_Position
的值以后,除了把裁剪坐标
转换成NDC
坐标以外,还会根据画布的宽高进行一次视口变换
,画布的宽高比不一样,渲染出来的效果就不一样。要解决这个问题,须要使用投影变换
对坐标先处理一道。通过投影变换
,咱们再任何尺寸的画布上看到的都会是一个正方形,也就是正方体的一个面,这时候咱们再让正方体旋转起来,就能够看到它的全部面了。Q:根据设置的颜色,前面
对应的色值是rgba(255, 0, 0, 1)
,也就是红色,为何看到的是绿色?
左手坐标系
,也就是 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>
效果以下:
完整代码请参考这里
Three.js
、Babylon
之类。若是只是为了完成效果,直接引入库就能够了。可是若是是为了深刻学习 webgl 开发,仍是要了解原生的写法。