随着现代浏览器端技术不短进步,web也逐渐支持了3D绘图技术。时至今日webGL这项技术已经在去年迎来了2.0版本。现如今数据可视化技术也日益繁杂起来,越来越多的数据可视化也逐渐从平面图表增加了许多新的3D类型图表,亦或者借助webGL的力量让数据渲染能力得到大幅度提升。
本人也是因为上司的工作安排,开始了webGL的学习之路。在学习的过程中发现目前网上学习原生webGL的资料少之又少,手头仅仅握着《webGL编程指南》这本书。
没错,就是这本。这本书也的确足够像我这样的初心者看了,本篇文章一共涉及三个部分:着色器程序和编译流程、四阶贝塞尔曲线的顶点数据生成函数、缓冲区的使用。后面我会附上源码,感兴趣的同学可以去看一下。
本篇内部分内容如果出现错误等,欢迎大家进行指点。
进入正片:
首先,webGL这一技术是由JavaScript和glsl着色器语言着两种语言组成,那么什么是glsl着色器语言呢?
着色器语言(简称shader)是一段运行在显卡上的程序,在webGL当中有两种着色器,一个叫做顶点着色器,另一个叫片元着色器。(来源:《webGL编程指南》)
通俗的讲,顶点着色器的作用即时描述一个三维物体的物理空间点坐标以及与这些坐标相关的信息。我们可以看到下面的立方体中有八个点(v0-v7)(图片来源:WebGL入门教程第1篇--六色立方 - CSDN博客)
我们可以看到这个立方体是有着八个点围城的面来组成的立方体,所以这些点被称作“顶点”。
片元着色器即根据着色器程序控制像素的颜色渲染,片元也可以理解为屏幕上显示的一个像素点。大致可理解为下图:(出处忘记了)
简单的介绍过了两个需要用到的着色器之后,我们进入下一阶段:
着色器程序编译
webGL的着色器程通常会以字符串的形式提前准备好,我们一起来看一下此次我们需要用到的着色器程序代码:
const VSHADER_SOURCE =
'attribute vec4 a_Position;\n' +
'void main(){\n' +
' gl_Position = a_Position;\n' +
'}\n';
const FSHADER_SOURCE =
'precision mediump float;\n' +
'uniform vec4 u_FragColor;\n' +
'void main() {\n' +
' gl_FragColor = u_FragColor;\n' +
'}\n';
复制代码
静态变量VSHADER_SOURCE就是顶点着色器代码,下面的则是片元着色器代码。
看代码外观上学过C语言的同学会觉得很有亲切感,但是要注意的是着色器代码并非C语言的代码。
我们注意看顶点着色器的第一行代码与片元着色器的第二行代码,可以看到他们的语句结构是一致的,由以下三部分组成:
存储限定符表示后面的变量为attribute变量,且该变量的数据将从着色器外部传入。注意:attribute并非代表着变量存储类型,而是类似于一种传输数据的限定标记,与int、float这样的声明存储空间的类型不同。变量a_Position的数据存储类型是指前方的vec4。
我们除去刚才看到的attribute着个存储限定符外,我们还看到了另外一个存储限定符——uniform。
那么我们应该如何使用这两个存储限定符呢,根据《webGL编程指南》一书中的描述总结成一句话即:与顶点数据相关采用 attribute,与顶点数据无关采用uniform。
顶点数据实际上就是用来描述每个顶点的坐标值,假设我们需要画一条线段,一条线段就需要两个点来连接,那么我们的顶点数据即可以向下面那样表示:
let position = [-1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
// x1, y1, z1, x2, y2, z2
复制代码
我们可以发现所有的数据均按照一定规律存储到了数组中,像这样描述顶点坐标值集合的数据就可以采用attribute这一存储限定符。
那么就仅仅只有顶点数据才可以采用attribute吗,答案是否定的。
在实际的填坑过程中,我发现像比如用来描述颜色的数据也可以用attribute来修饰,目前我发现attribute存储类型只会出现在顶点着色器中(此处仍待考证,如有错误请指正)。
uniform存储限定符一般用作存储颜色、各种矩阵(视图、模型、投影三大矩阵,我个人简称MVP。。→_→)或其他类型数据。
好了,废了这么多口舌,接下来终于进入到编译阶段,在这个阶段一共分为以下几个步骤:
- 创建着色器对象
- 向着色器对象传入着色器源码
- 编译源码
- 创建着色器程序
- 为着色器程序分配着色器对象
- 连接程序
接下来我们一起来看一下着色器程序源码编译流程这一部分的代码:
// 分别创建顶点着色器对象与片元着色器对象
let shader_V = gl.createShader(gl.VERTEX_SHADER);
let shader_F = gl.createShader(gl.FRAGMENT_SHADER);
// 向相应的着色器对象传入字符串源码
gl.shaderSource(shader_V, VSHADER_SOURCE);
gl.shaderSource(shader_F, FSHADER_SOURCE);
// 编译顶点着色器源码
gl.compileShader(shader_V);
let isCompiled_V = gl.getShaderParameter(shader_V, gl.COMPILE_STATUS);
if (!isCompiled_V) {
throw new Error('compile Shader is failed');
}
// 编译片元着色器源码
gl.compileShader(shader_F);
let isCompiled_F = gl.getShaderParameter(shader_F, gl.COMPILE_STATUS);
if (!isCompiled_F) {
throw new Error('compile Shader is failed');
}
// 创建着色器程序
let program = gl.createProgram();
// 将着色器对象分配给着色器程序
gl.attachShader(program, shader_V);
gl.attachShader(program, shader_F);
// 连接着色器程序
gl.linkProgram(program);
let isLinked = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!isLinked) {
throw new Error('link Shader is failed');
}
// 启用指定的着色器程序
gl.useProgram(program);
复制代码
细心的同学会发现,我最后一行代码并没有在刚才的流程上出现。这是因为在多物体渲染中很可能存在使用不同的着色器程序代码这种情况,也因此会根据情况产生多个着色器程序,并且在调用渲染函数时,webGL会以当前启用的着色器程序为基准去渲染,因此着色器程序需要在各种情况下去切换,所以他不能被算作编译流程当中。
获取存储限定符地址
编译过着色器程序之后,我们将两个存储限定符相关的变量地址获取到,为我们下一步工作做准备。
// 获取存储限定符类型变量地址
let a_Position = gl.getAttribLocation(program, 'a_Position');
let u_FragColor = gl.getUniformLocation(program, 'u_FragColor');
复制代码
我们可以看到attribute、uniform这两种存储限定符变量获取地址的API稍有不同,因此在使用的时候需要注意区分。
贝塞尔曲线公式
接下来我们需要开始准备各种绘制图形所需的顶点数据了。切合此次主题,我们需要用到贝塞尔曲线公式来计算出顶点,从而生成贝塞尔曲线。
/**
* 生成四阶贝塞尔曲线定点数据
* @param p0 起始点 { x : number, y : number, z : number }
* @param p1 控制点1 { x : number, y : number, z : number }
* @param p2 控制点2 { x : number, y : number, z : number }
* @param p3 终止点 { x : number, y : number, z : number }
* @param num 线条精度
* @param tick 绘制系数
* @returns {{points: Array, num: number}}
*/
function create3DBezier(p0, p1, p2, p3, num, tick) {
let pointMum = num || 100;
let _tick = tick || 1.0;
let t = _tick / (pointMum - 1);
let points = [];
for (let i = 0; i < pointMum; i++) {
let point = getBezierNowPoint(p0, p1, p2, p3, i, t);
points.push(point.x);
points.push(point.y);
points.push(point.z);
}
return points;
}
/**
* 四阶贝塞尔曲线公式
* @param p0
* @param p1
* @param p2
* @param p3
* @param t
* @returns {*}
* @constructor
*/
function Bezier(p0, p1, p2, p3, t) {
let P0, P1, P2, P3;
P0 = p0 * (Math.pow((1 - t), 3));
P1 = 3 * p1 * t * (Math.pow((1 - t), 2));
P2 = 3 * p2 * Math.pow(t, 2) * (1 - t);
P3 = p3 * Math.pow(t, 3);
return P0 + P1 + P2 + P3;
}
/**
* 获取四阶贝塞尔曲线中指定位置的点坐标
* @param p0
* @param p1
* @param p2
* @param p3
* @param num
* @param tick
* @returns {{x, y, z}}
*/
function getBezierNowPoint(p0, p1, p2, p3, num, tick) {
return {
x : Bezier(p0.x, p1.x, p2.x, p3.x, num * tick),
y : Bezier(p0.y, p1.y, p2.y, p3.y, num * tick),
z : Bezier(p0.z, p1.z, p2.z, p3.z, num * tick),
}
}
复制代码
如果我们只需要获取整条贝塞尔曲线上所有的顶点数据集,那么我们就需要调用create3DBezier()函数并填入指定参数即可。关于四阶贝塞尔曲线公式等数学知识请自行百度,本人也仅是照着公式将函数敲出来了→_→。
// 传入顶点数据
let bezierPoint = create3DBezier(
{ x : -0.7, y : 0, z : 0 }, // p0
{ x : -0.25, y : 0.5, z : 0 }, // p1
{ x : 0.25, y : 0.5, z : 0 }, // p2
{ x : 0.7, y : 0, z : 0 }, // p3
20,
1.0
);
复制代码
通过该函数,我们得到了顶点数据的集合,格式与上面我们举例子绘制线条的格式是一样的,只是数据量上有差异。
顶点数据类型
得到数据之后我们并没有完成对数据的处理,因为接下来我们需要将数据类型进行转换。那么转换的方式非常简单:
let points = new Float32Array(bezierPoint);
复制代码
我们可以看到直接将Array类型的数组变成了Float32Array类型了。那么有的同学就会问,为什么需要这么做?这么做的好处是什么?
首先我们把第一个问题先放一下,因为会在后面讲到缓冲区操作相关时我会为大家介绍,那么我们来看看用类型化数组的好处是什么呢?
关于Array类型数组对于JavaScript功底比较好的同学可能会知道,JavaScript的数组(Array)严格上来讲还不能被称为是“数组”,因为他仅仅是一个类似于对象的存在,并且在内存上的存储方式上与类型化数组有截然不同的方式。
从上图中我们可以看出来,一般的Array类型数组在内存当中并不是一串连续的内存空间地址,但Float32Array则是连续的,因此两者在数组存取上的速度肯定有着明显的区别。同时在数据进行大量生成时Array.push()这种方式虽然十分便利,但重复的分配空间操作会带来很大的性能影响,而使用类型化数组虽然便利度比Array稍微逊色一点,但带来的性能优化还是很可观的。曾经公司内部将6W条数据进行顶点数据的生成,里面用到了concat、push等操作,就会发现性能问题相当明显。
好了,解释完了其中一个问题过后,我们带着一个未解决的问题进入下一个阶段:
缓冲区操作
同学们首先会好奇,这个缓冲区的作用到底是什么?不用他是否可以绘制图形?习惯性先回答第二个问题。。硬要较真的话不用缓冲区当然是可以的。但不用缓冲区你也没办法绘制出绚丽的webGL图形→_→
那么回过头来看,缓冲区的作用究竟是什么呢?借助缓冲区我们可以一次性将大量顶点数据传入到glsl着色器当中。这部分内容我建议大家去阅读一下《webGL编程指南》一书,根据书的章节一点点了解,会更加贴切的理解为什么需要缓冲区。
那么接下来让我们来看看缓冲区部分的代码吧:
// 创建缓冲区
let vertexBuffer = gl.createBuffer();
// 绑定缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// 向缓冲区写入数据
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);
// 分配缓冲区至指定着色器变量地址
gl.vertexAttribPointer(a_Position, 3, gl.FLOAT, false, 0, 0);
// 连接地址
gl.enableVertexAttribArray(a_Position);
复制代码
从上至下我们大体可分为五个步骤:
- 创建缓冲区
- 绑定缓冲区
- 向缓冲区写入数据
- 分配缓冲区至指定着色器变量地址
- 连接至该着色器地址
关于这几个步骤的缓冲区操作其实是可以视情况调用的,也就是说不一定非要按着顺序来,在这里只是给对webGL感兴趣的同学进行简单的说明。
并且关于缓冲区的操作也可以按照情况可以进行优化。就在这里不进行细讲了,如果已经有了一定基础的同学,可以尝试阅读一下three.js的源码,在three.js内对缓冲区操作进行了许多的优化。
到了这里我该对上面残留的那个类型化数组问题进行解答了。其实类型化数组不止Float32Array一种,类型化数组共分为以下几种:
Int8Array
Uint8Array
Int16Array
Uint16Array
Int32Array
Uint32Array
Float32Array
Float64Array
复制代码
具体选用哪种类型化数组是要和你的缓冲区数据类型相匹配的,关键就在于gl.vertexAttribPointer()这个API身上。那么我们来具体看一下这个函数的参数:
根据这个表中给出的信息我们可以看到第三个参数就是用来指定缓冲区数据类型的,那么第三个参数都可以是哪些类型呢?我们继续看下面一个表:
这样大家恐怕就清楚什么情况下采用何种类型化数组了吧。
之后我们把剩下的颜色值也传入到glsl中吧:
// 传入颜色
gl.uniform4fv(u_FragColor, [0.0, 1.0, 1.0, 1.0]);
复制代码
绘制图形
到了这里就即将进入结尾阶段了,首先我们先将画布作清空处理:
// 设置颜色缓冲区清空颜色
gl.clearColor(0.0, 0.0, 0.0, 1.0);
// 清空颜色缓冲区
gl.clear(gl.COLOR_BUFFER_BIT);
复制代码
清空画布之后,我们就开始调用绘制函数吧
// 绘制
gl.drawArrays(gl.LINE_STRIP, 0, bezierPoint.length / 3);
复制代码
关于gl.drawArrays()函数,首先第一个参数即指定绘图类型,第二个参数是指从第几个顶点开始绘制,第三个参数即绘制图形需要使用多少个顶点。
那么他的第一个参数都有哪些呢?我们继续看表吧
以上就是webGL的基本绘图类型,我们此次绘制的是一个单条多点的线段,因此我们采用的是gl.LINE_STRIP。
完成以上代码后我们来看看效果吧~
到此我们的小案例就算完成了~
那么我也为大家展示一下目前公司内部运用原生webgl做出来的两个相似的demo:
demo1
demo2
谢谢大家支持,如有任何问题请在留言处进行批评指正。
贝塞尔曲线demo地址:Axiny/webgl-bezier-demo