本文作者:ivweb qcyhust
导语
在网页上绘制3D图形已经不再是什么新鲜的事情,时不时都能遇到一个炫酷的3D模型让人感叹未来的无限可能,在某些使用场景下,用3D呈现内容会更能抓住用户的注意力,新技术如AR、3D全景的不断成熟也在加速构建3D世界的脚步。
技术上我们已经有足够多的手段去实现一个三维世界,比如css3可以实现3D变换、动画,html5 canvas 2D画布可以模拟3D物体甚至实现3D的效果。而本文要讨论的webgl相对来说会更加底层,它建立在OpenGL ES 2.0( 嵌入式OpenGL,一个适用于移动设备的3D图形标准 )之上,对曾经从事过OpenGL 3D图形开发的人员来说非常容易入门。
WebGL本质来讲仍然是html5画布的功能,浏览器提供一系列的编程接口来在html和JavaScript的环境中绘制3D图形。它利用图形硬件( GPU )加速绘制,而且直接在浏览器( 浏览器的支持情况 )中运行不需要额外的插件支持。WebGL的绘制代码相对于canvas 2d来说会显得非常复杂,比如绘制一个矩形,canva 2d只需要不超过20行代码即可,而利用WebGL的话,也许会写出将近200行。不过目前有很多有优秀的3D库来帮助开发者减少重复工作,高效的构建WebGL应用(比如Three.js)。本文不会涉及WebGL第三方库的使用,利用原生WebGL API从绘制基本图形三角形出发,探讨WebGL在二维画布上的绘制。
需要提前知道的
WebGL学习曲线相对来说比较陡峭,学习人员至少要熟悉HTML和JavaScript,除此之外还需要了解一点点其他的内容,WebGL的API将在下文代码中使用到时介绍。
- 3D图形基础(3D坐标,视点、目标点、上方向,投影等)
- 线性代数矩阵基础(矢量点积、叉积,齐次坐标,矩阵运算,矩阵变换等)
- OpenGL ES 2.0基础语法(下文介绍)
绘制顶点
这一节,我们仅仅在页面绘制顶点,利用缓冲数组buffer绘制多个点,探讨绘制的过程。
首先准备html文件:
代码语言:javascript复制<!doctype html>
...
<body>
<canvas id="webgl" width="600" height="600">
浏览器不支持画布
</canvas>
...
</body>
...
WebGL依然是在HTML5 画布范畴,所以在html中使用canvas标签,来提供画布上下文。案例中利用了一些工具库来帮助我们把重点放在WebGL应用上。接下来在js文件中创建webgl上下文。
代码语言:javascript复制function main() {
// 获取canvas上下文
const canvas = document.getElementById('webgl');
// 获取webgl上下文
const gl = getWebGLContext(canvas);
// 初始化着色器
initShaders(gl, VSHADER_SOURCE, FSHADER_SOURCE);
// 初始化缓冲
const n = initBuffers(gl);
// 清除背景色和颜色缓存
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制
gl.drawArrays(gl.POINTS, 0, n);
在main函数中获取canvas传递给getWebGLContext用来获取webgl上下文,看看getWebGLContext的主要实现:
代码语言:javascript复制function getWebGLContext(canvas) {
let gl = null;
try {
// 如果没有webgl上下文,尝试实验版的webgl
gl = canvas.getContext('webgl') || canvas.getContext('exprimental-wegl')
} catch {
...
}
return gl;
}
回到main函数中,接下来初始化一个叫着色器的东西。光线照射在材质上产生的效果也就是着色,在WebGL中着色分为两种:
- 顶点着色器:对顶点进行着色
- 片段着色器:绘制缓存中的片段进行着色
- 来看看着色器代码的简单实现:
// 顶点着色器
const VSHADER_SOURCE =
`attribute vec4 a_Position;
attribute vec4 a_Color;
varying vec4 v_Color;
void main() {
gl_Position = a_Position;
gl_PointSize = 10.0;
v_Color = a_Color;
}`;
// 片段着色器
const FSHADER_SOURCE =
`precision mediump float;
varying vec4 v_Color;
void main() {
gl_FragColor = v_Color;
}`;
着色器的代码是一种类C风格的OpenGL ES着色语言(GLSL ES),顶点着色器和片段着色器用字符串表示,着色器代码分别用VSHADER_SOURCE,FSHADER_SOURCE两个变量存储。着色器中可以定义变量,变量一般有三类:
- attribute变量:与顶点有关的变量如位置,颜色
- uniform变量:与顶点无关的共享变量,在所有顶点、片段中都相同
- varying变量:用来从顶点向片段发送的变量
- 内置变量:如gl_Position、gl_FragColor用来指定顶点、片段的变量
顶点着色器中定义了顶点位置position,顶点尺寸pointsize,还向片段着色器传入颜色属性,片段着色器中
precision mediump float;
定义了浮点类型数据的精度。
着色器代码需要传入initShaders中来初始化着色器,最终得到一个包含顶点着色器和片段着色器的程序对象,这个程序对象附加到gl上下文中供后面的代码与着色器代码建立关联。
初始化着色器需要7步:
- 创建着色器对象 createShader
- 把着色器代码载入到着色器对象中 shaderSource
- 编译着色器 compileShader
- 创建程序对象 createProgram
- 编译过的着色器附加到程序对象中 attachShader
- 链接程序对象 linkProgram
- 调用程序对象 useProgram
来看看initShaders的具体实现(去掉了异常处理):
代码语言:javascript复制function initShaders(gl, vshader, fshader) {
// 分别加载顶点着色器和片段着色器
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
// 4.创建程序对象
const program = gl.createProgram();
// 5.编译过的着色器附加到程序对象中
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
// 6.链接程序对象
gl.linkProgram(program);
// 7.调用程序对象
gl.useProgram(program);
gl.program = program;
return true;
}
function loadShader(gl, type, source) {
// 1.创建着色器对象
const shader = gl.createShader(type);
// 2.把着色器代码载入到着色器对象
gl.shaderSource(shader, source);
// 3.编译着色器
gl.compileShader(shader);
// getShaderParameter 检查编译状态
const compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
if (!compiled) {
const error = gl.getShaderInfoLog(shader);
console.log('Failed to compile shader: ' error);
gl.deleteShader(shader);
return null;
}
return shader;
}
接下来建立缓冲保存需要的数据。WebGL是一种即时模式API,每一帧都需要完全重新绘制,WebGL没有提供能保存绘制物体状态的API,所以需要代码自己提供对绘制物体状态的保存。在绘制顶点时,把顶点数据以数组的形式存储,这个数组就是所说的缓冲,待绘制的数据都应该在缓冲中定义。同时,为了加快数组的访问速度和减少内存消耗,浏览器专门为WebGL引入了缓冲数组(Array Buffer)这个新的数据类型。最后将缓冲数组写入到WebGL的缓冲对象中。 WebGL建立缓冲对象:
- 创建buffer对象 createBuffer
- 绑定buffer到缓冲对象上 bindBuffer
- 向缓冲对象写入数据 bufferData
在初始化缓冲数据initBuffers中,需要完成:
- 从程序对象中获取相应属性 getAttribLocation
- 向顶点写入缓冲数据 vertexAttribPointer
- 使用缓冲数据建立程序代码到着色器代码的联系 enableVertexAttribArray
function initBuffers(gl) {
// 定义顶点数据 x, y, r, g, b
const vertices = new Float32Array([
0.0, 0.5, 1.0, 0.0, 0.0,
-0.5, -0.5, 0.0, 1.0, 0.0,
0.5, -0.5, 0.0, 0.0, 1.0,
]);
const n = 3;
// 创建buffer对象
const vertexBuffer = gl.createBuffer();
// 创建buffer对象
const FSIZE = vertices.BYTES_PER_ELEMENT;
// 绑定buffer到缓冲对象上
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
// 向缓冲对象写入数据
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 从程序对象中获取相应属性
const a_Position = gl.getAttribLocation(gl.program, 'a_Position');
const a_Color = gl.getAttribLocation(gl.program, 'a_Color');
// 向顶点写入缓冲数据
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, FSIZE * 5, 0);
// 使用缓冲数据建立程序代码到着色器代码的联系
gl.enableVertexAttribArray(a_Position);
gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 5, FSIZE * 2);
gl.enableVertexAttribArray(a_Color);
return n;
}
使用BYTES_PER_ELEMENT来获取缓冲数组每个元素的字节长度来帮助从数组中获取需要的数据。数组中存储有顶点位置和颜色信息,将它们都写入ARRAY_BUFFER中,getAttribLocation方法用来从程序对象中获取属性索引,a_Position和a_Color都是索引值。vertexAttribPointer方法从缓冲中取出数据并写入向程序对象的属性中,参数分别表示指定属性的索引值,指定每一个属性值的长度,数据类型,是否归一化,指定属性字节长度步幅,偏移值,gl.vertexAttribPointer(a_Color, 3, gl.FLOAT, false, FSIZE * 5, FSIZE * 2)
表示向颜色属性写入数据,数据长度是5个FSIZE,从第二个FSIZE开始。补充说明位置信息为[x, y, z, w]的4个分量的向量表示,这样的坐标叫做齐次坐标,将x,y,z分别除w就是空间坐标[x/w, y/w. z/w],当w为1时,x,y,z也就和在空间坐标中的值一样,在写入数据时不指定z和w的值会默认赋上0.0和1.0,同理,颜色信息使用RGBA表示,代码中Alpha值没有指定时会默认为1.0不透明。
现在最后的工作就是绘制顶点,如果没有指定视口(下文介绍)的话,视口会被初始化位一个原点在(0,0)的矩形,矩形高宽为画布的高宽。
// 清除背景色和颜色缓存
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
// 绘制
gl.drawArrays(gl.POINTS, 0, n);
每一次绘制都会清除画布,clearColor方法指定清除颜色,clear方法传入gl.COLOR_BUFFER_BIT表示把颜色缓存清除为clearColor指定的颜色。调用drawArrays绘制点类型就可以绘制出三个顶点。
WebGL流程
前面的代码虽然还没有涉及三维空间的知识,但是已经大致说明了WebGL绘制程序的几个步骤。
- 获取webgl上下文
- 初始化着色器
- 初始化缓冲数据
- 清除缓存
- 绘制 在这几步的基础上进行扩充就能实现动画,交互,3D世界等高级功能。
在上面的代码中,通过调用多个API把模型的绘制信息都传递给webgl后,webgl此时已经拥有了两个可编程着色器,模型如何绘制的信息(位置,尺寸等)。调用drawArrays后就绘制出三个不同颜色的定点,这里来介绍一下webgl绘制的流程。
第一个阶段是顶点着色器对顶点进行绘制,在这个阶段定义顶点位置,尺寸信息,可以通过变换矩阵实现绘制对象在场景中的位置转变。
第二个阶段是图元装配,webgl把已经着色的顶点装配成三角形,线段等几何图元。对于每一个图元,还要判断是否位于屏幕上的可见区域(可设置)中,如果不在可见区域中,则需要删掉,剩下的部分进入下一个阶段。
然后是光栅化阶段,这个阶段就是把图元转换魏一个个片段,然后把片段传递给片段着色器。片段可以先理解为一个像素,但是在逐片段绘制阶段会因为深度、融合等过程而丢弃一些片段,所以webgl中的片段和像素还是有区别的。
第四个阶段是片段着色器阶段,通过输入或是自定义片段信息(颜色,坐标系等)绘制出每一个片段,在上面的代码中,颜色通过varying变量传入,再进行线性插值得到当前片段的颜色。片段着色器需要指定浮点类型数据的精度,precision mediump float
。
写入绘制缓存前的最后一个阶段是逐片段操作,这一步包含几个可设置启动禁用的子操作。比如深度测试缓存可以对片对z值进行比较,决定是否丢弃片段,融合操作可以将传入片段的颜色如已经在颜色缓存中的片段进行组合,一般用在透明对象中。
2D图形绘制
在前面的代码中,已经绘制出了三角形的三个顶点,并且这三个顶点的z值都为0,那么怎么绘制一个二维平面的三角形呢?只需要简单修改之前的代码。
代码语言:javascript复制// 绘制
gl.drawArrays(gl.TRIANGLES, 0, n);
将绘制这一步的drawArrays的第一个参数由POINTS修改为TRIANGLES即可。在WebGL中有两个方法绘制缓冲数据: drawArrays
要使用drawArrays方法,需要将buffer对象(由createBuffer方法创建)绑定到ARRAY_BUFFER上,然后把数据写入到buffer对象中,在激活连接(enableVertexAttribArray, vertexAttribPointer)后就可以按顶点的顺序依次绘制。
drawArrays(mode, first, count) 该方法有三个参数,mode表示要渲染的图元类型,它包括 点 POINTS: 绘制顶点 线 LINTS: 顶点p1,p2,p3,p4,按(p1-p2, p3-p4)的顺序绘制线 LINE_LOOP: 顶点p1,p2,p3,p4,按(p1-p2, p2-p3, p3-p4, p4-p1)的顺序绘制线,最终是一个loop LINE_STRIP: 顶点p1,p2,p3,p4,按(p1-p2, p2-p3, p3-p4)的顺序绘制线
三角 TRIANGLES: 将顶点绘制成三角形 TRIANGLE_STRIP: 绘制成三角带,比如p1, p2, p3, p4绘制成(p1, p2, p3和p2, p3, p4)的多边形 TRIANGLE_FAN: 绘制成三角扇形,比如p1, p2, p3, p4绘制成(p1, p2, p3和p1,p3, p4)扇形 first表示开始缓冲数组中哪一个点作为起始的顶点,count表示总共需要绘制的顶点数。 drawElements 该方法在有多个共享顶点的情况下可以增加顶点的重用程度,按照顶点索引的顺序来绘制相应的图元。drawElements利用包含顶点数据的ARRAY_BUFFER,还要使用一个顶点数据索引的缓冲ELEMENT_ARRAY_BUFFER。顶点的绘制顺序有这个索引来决定。 drawElements(mode, count, type, offset) 参数mode与前面drawArrays方法的一样,定义了要绘制的图元类型。count定义ELEMENT_ARRAY_BUFFER上的索引数量。type是索引的类型,一般指定为UNSIGNED_BYTE或UNSIGNED_SHORT。offset是索引的偏移量。 回到代码中,要绘制一个三角形就可以采用三角形图元TRIANGLES来绘制,也可以试试其他图元,看看能绘制出什么样的图形。 现在来试一下矩形怎么绘制,在之前代码的基础上,需要增加一个顶点p4,利用基本三角就可以绘制出。 如图所示,使用图元类型为TRIANGLE_STRIP,p1,p2,p3和p2,p3,p4两个三角形就可以组成矩形,修改顶点数组:
代码语言:javascript复制const vertices = new Float32Array([
-0.5, 0.5, 1.0, 0.0, 0.0,
0.5, 0.5, 0.0, 1.0, 0.0,
-0.5, -0.5, 0.0, 0.0, 1.0,
0.5, -0.5, 1.0, 1.0, 1.0,
]);
const n = 4;
前面介绍的drawElements绘制方法又是什么使用的呢?现在尝试使用drawElements和顶点索引绘制一个多个六变形组成的图形。这个像蜂窝的图案由6个正六边形组成。首先依次计算出6个六边形的中点图案放入中心点数组中,然后遍历这个中心点数组,结合六边形的宽(width)高(height),得出每一个顶点的坐标:
- x (-width / 2), y 0.0
- x (-width / 4), y (height / 2)
- x (width / 4), y (height / 2)
- x (width / 2), y (0.0)
- x (width / 4), y (-height / 2)
- x (-width / 4), y (-height / 2) 采用LINES图元绘制图案,对于每一个六边形,顶点索引是[p0, p1], [p1, p2], [p2, p3], [p3, p4], [p4, p5], [p5, p6],然后将索引写入绑定在ELEMENT_ARRAY_BUFFER上的buffer中,使用drawElements方法绘制出来。
// 六边形宽高
const width = 0.4;
const height = width * Math.cos(Math.PI / 6);
// 顶点数组
const vertexData = [];
// 索引数组
const indexData = [];
// 中心点坐标
const points = [
[-width * 3 / 4, height / 2],
[0, height],
[width * 3 / 4, height / 2],
[width * 3 / 4, -height / 2],
[0, -height],
[-width * 3 / 4, -height / 2]
];
points.forEach((point, i) => {
vertexData.push(
point[0] (-width / 2), point[1] 0.0, 1.0, 0.0, 0.0,
point[0] (-width / 4), point[1] (height / 2), 0.0, 1.0, 0.0,
point[0] (width / 4), point[1] (height / 2), 0.0, 0.0, 1.0,
point[0] (width / 2), point[1] (0.0), 1.0, 0.0, 0.0,
point[0] (width / 4), point[1] (-height / 2), 0.0, 1.0, 0.0,
point[0] (-width / 4), point[1] (-height / 2), 0.0, 0.0, 1.0);
indexData.push(points.map((_, j) => {
6 * i j, 6 * i j 1
});
});
const vertices = new Float32Array(vertexData);
const indices = new Uint16Array(indexData);
const vertexBuffer = gl.createBuffer();
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 写入ELEMENT_ARRAY_BUFFER
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
...
// 绘制
gl.drawElements(gl.LINES, n, gl.UNSIGNED_SHORT, 0);
利用LINE_STRIP或LINE_LOOP还能有更高效的绘制方案。
着色器语言 GLSL ES
着色器代码用GLSL ES编写,从来源看,GLSL是OpenGL着色器语言的一个功能简化版,本来的目标是嵌入式设备,因此简化的GLSL ES相对来说占用更低的硬件消耗和更少的性能开销。语法上,GLSL语法与C语言非常类似,基础的变量,赋值,类型转换,代码执行次序都与C语言相同,并且在矢量和矩阵运算上提供很多的简便方法,非常适合图像处理,这里介绍一些在编写着色器代码时可能遇到的特性。
变量
GLSL ES中有全局变量和局部变量的概念,在之前的代码中,声明在函数外的a_position,a_color在main函数之外,他们都是全局变量,声明在函数内部的变量就是局部变量。之前我们用attribute,varying来修饰变量,它们都叫变量的限定字,除了上面提到的,存储限定字还有const和uniform。
const
和es6中的const概念一样,GLSL ES中的const限定字表示修饰的变量的值不能被改变,并且声明同时就要初始化。比如const vec4 color = vec4(1.0, 0.0, 0.0, 1.0);
。再改变color的值就会报错。
attribute
只会出现在顶点着色器中,只能被声明为全局变量,用来表示与逐顶点相关的值,比如顶点的坐标。
uniform
可以出现在顶点着色器和片段着色器中,只能被声明为全局变量,它表示顶点或偏远共用的数据,比如顶点的坐标都共用一个变换矩阵,那个变换矩阵就可以声明为:uniform mat4 transformMatrix;。
varying
与uniform一样,varying也只能被声明为全局变量,它是将顶点着色器中的数据传递给片段着色器,只需要在两种着色器中都声明同名,同类型的变量。顶点着色器的varying变量经过光栅化的过程,对其进行内插得到的结果再传递给片段着色器。
GLSL新引入了精度限定字,给每种数据都设置精度,帮助着色器提高运行效率,减少内存开支。WebGL支持三种精度hightp(高精度,顶点着色器的最低精度),mediump(中精度,片段着色器的最低精度)和lowp(低精度)。比如highp vec4 color
;指定color变量具有高精度。在片段着色器代码中,用precision mediump float;来为浮点型数据都制定中精度。如果没有单独指定精度,都会采用数据类型的默认精度,但是片段着色器的float类型没有默认精度,所以需要手动指定。
取样器
GLSL ES支持一种叫取样器的类型,通过该类型的变量可以访问纹理。取样器有两种类型:sampler2D和samplerCube。取样器是共用的数据,所以被限定为uniform变量。后续文章介绍纹理时会演示它的使用。
discard
GLSL ES同样支持的程序流程控制和C语言很相似,同样可以通过for语句来控制循环。在使用for循环时,除了C语言中就有的continue和break控制语句外,还有一个discard。 discard在片段着色器中被使用,当它被调用时,表示放弃当面片段,直接处理下一个片断。比如要绘制圆形的点:
代码语言:javascript复制void main() {
// 计算片段到点中心的距离
float dist = distance(gl_PointCoord, vec2(0.5, 0.5));
if(dist < 0.5) {
// 绘制红色片段
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
} else {
// 放弃绘制
discard;
}
}
函数
函数的定义也接近C语言,除了自定义函数外,GLSL提供很多内置函数来帮助处理图片,比如计算内积(dot),变量归一化(normalize),获取纹素(texture2D)等。
矢量和矩阵
矢量和矩阵常用来处理计算机图形,在GLSL中,用vec2,vec3,vec4来变数具有相应后缀数子的浮点元素的矢量,ivec表示矢量元素类型为整形数,同理,bvec表示元素类型为布尔值。mat2,mat3,mat4分别表示22,33,4*4的浮点数元素矩阵。
在矢量和矩阵赋值的时候要注意元素类型和数量的一致。比如:vec4 position = vec4(0.0, 0.0, 0.0, 0.0);
。也可以使用矢量的组合来赋值给一个新的矢量或矩阵,比如:vec2 v1 = vec2(1.0, 2.0); vec2 v2 = vec2(3.0, 4.0); vec4 v3 = vec4(v1, v2)
;v3也就是(1.0,2.0,3.0,4.0)。也可以构成矩阵,比如:mat2 m1 = mat2(v1, v2);
矢量的访问可以用.运算符。x,y,z,w用来访问顶点左边分量,vec4 p = vec4(1.0, 2.0, 3.0, 1.0);p.x 表示 1.0,p.w表示1.0。同时多个分量共同放在点运算符后可以取出多个分量,也就是混合:p.xyz 表示 (1.0,2.0,3.0)。除此之外,颜色分量可以用r,g,b,a来访问元素,纹理坐标可以用s,t来访问。对于一个vec4的矢量来说,x,r,s都可以访问第一个元素。
和js的数据类似,矢量和矩阵也可以用[]运算符访问。矢量中,[]运算符中的数值表示索引值,矩阵中,第一个[]表示列数,第二个[]表示行数。
GLSL支持矢量、矩阵的运算,矢量和矩阵的可以直接用操作符指定运算,运算遵循线性代数中的矩阵运算基本规则。
总结
在开始WebGL绘制三维图形之前需要熟悉WebGL的2D图形的绘制,关于纹理贴图,光照等内容等下一篇再介绍吧。
原文出处:IVWEB社区 未经同意,禁止转载