一起来玩玩WebGL--第二弹

avatar
阿里巴巴 前端委员会智能化小组 @阿里巴巴

文/阿里淘系 F(x) Team - 赋行

书接上文

一起来玩玩WebGL--第一弹  中主要介绍了一下什么是WebGL,和大家一起理解了这货到底是个啥东西,不知道大家还记得多少,毕竟这一更也太久了,忘记了的话可以回去快速回顾一下哦。其实嘛,内容不多,就是图形编程的简单过程,最重要的还是,WebGL可以为HTML5的Canvas提供硬件加速,也就是说在浏览器用JS调用GL的API进行渲染咯,哇塞(kao),JS真的是啥都可以干啊!然后让大家感受了OpenGLES(WebGL是基于它的嘛)的渲染管线以及着色语言是怎么编写的,只不过还没有去实践写写例子罢了;今天这一弹我就来分享一下我的入门例子咯!

话再多说一句,我也是初学者,是前端初学者,更是WebGL的初学者,不敢说这几篇文章是在教大家什么,这只是我的学习记录,因为刚转前端,为了培养兴趣,找点东西玩玩,恰好就碰上了这玩意,那就借助下班业余时间从零开始学学,然后总结分享出来与大家交流学习罢了,当中不免会有不少理解错误的地方,大家可以评论指出一起交流学习心得啊。有同学问过我是怎么学习的,其实很简单,无非就是网上去搜索各种资料查阅、买书阅读,使劲去啃,然后理解,总结,找找例子代码练习,时间一长,就学会了。这过程经历的内容呢远比这几篇文章表达的内容量大得多,文章只是我的一个学习过程思路,未必适合每一个人,就好比我也看了好多别人的文章以及书籍,他们的思路也不一定适合我,只不过是这过程中我一直在消化他们的内容,最后总结转化为自己的理解。

什么是Canvas

还记得不,第一次百科了解WebGL的时候,Get到的三个点就是:JS、Canvas、OpenGLES,那好,JS我们都会啦,现在就来了解一下Canvas;各位看客都是前端大佬,Canvas肯定都会,就我这种刚入坑前端的可能还是需要学习一下的哦。这里我也不想浪费各位大佬的时间,我自个去w3school学习就好了,其实我是Android出身的嘛,也用过Canvas,就是一个画布嘛,拿到画笔Paint,然后调用API在上面画东西嘛,点、线、圆、圆弧、矩形、Bitmap(图)等等啦。在HTML5也差不多的,通过组件获取到context以后就调用各种各样的API来绘制元素。回想一下HTML的历史,以前想要在浏览器上显示图像,也就只能使用标签了,当然还有视频、flash这些方案嘛,不过这还都局限了伟大的艺术创造家(程序员- -!)的发挥啊,直到了Canvas的出现,就如马良有了神笔啊!

image.png

硬件加速和软件加速

那么问题来了,记得百科原话是这么说的,“WebGL可以为HTML5 Canvas提供硬件3D加速渲染”,啥是硬件加速?啥又是软件加速呢?

普遍的解释就是说,软件加速就是程序员写代码来执行,往深一层呢,就是说在CPU上运行程序,硬件加速就是不用程序员写代码来执行了,交给了硬件自己去执行,说得最多的就是GPU硬件加速了。说得好像程序员解放了双手,全都是硬件搞定了,所以速度快了?其实嘛,广义上来说,CPU也是硬件啊,为啥就不是硬件加速了呢?这得归于操作系统的功劳,把底层一切的硬件都“软”起来了,大学我们都学过了计算机组成原理和数字逻辑(题外话,想补这块知识的朋友们,我想安利大家一本书《编码:隐匿在计算机软硬件背后的语言》,写得真的非常好!),其实CPU就是加法器嘛,现代的CPU也是提供了很多指令集;只要我们不写汇编,而是用高级语言进行编程,那么底层关于硬件的所有东西都是透明化的,所以我们写的是“软”件咯。

image.png 

想象一下,如果我们程序性能是瓶颈了,你觉得高级语言的编译器可能实现不好,没有最大化发挥你的CPU能力,于是你去了解了你的CPU所有特性,然后自己写了汇编调用你的CPU的指令集,利用各种高速缓存和寄存器来实现你的功能,果真性能就提升了!这是不是针对CPU的硬件加速了啊?

硬件加速无非就是往底层去了解了硬件的特性来编程,实际上还是程序员在干活,只不过是大家的领域不同了,之前也说过OpenGL和DirectX就是在中间层针对图形这块帮我们做了很多事情,而GPU和CPU不同的特性就是它有非常多的核,几百上千都有,因此可以并发去运行,这就是他加速的关键核心啊。

image.png

HelloWorld!

综合前面所有的学习理解到,Canvas它的绘制过程其实都是在CPU里面完成的,消耗的都是CPU的计算时间,最后产出一帧图像,copy到了显存,让GPU显示就完了。有了WebGL以后,Canvas的绘制过程就可以放到了GPU去执行,CPU就可以释放出来干别的业务逻辑事情啦。

终于要来第一个代码了!先上一下效果吧,如下图所示,在绿色的canvas画布上绘制了一个红色的点:

image.png

大家用canvas的api三两下就实现了,那么如果用WebGL是如何做到的呢?按下面步骤一步一步来试试看。

编写HTML代码

既然是浏览器运行的代码,当然就是先来写html文件来,新建一个hellworld.html文件,编写以下代码:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8"/>
        <title>Hello World, WebGL!</title>
    </head>
    <body onload="main()">
        <canvas id="webgl" width="800" height="800">Please use a browser that support "canvas".</canvas>
        <script type="text/javascript" src="Helloworld.js"></script>
        <script type="text/javascript" src="Helloworld-Shaders.js"></script>
    </body>
</html>

可以看到代码其实好简单啊,就是一个简单的HTML文件使用了标签(如果浏览器不支持canvas的话就会显示提示文案了),引入了两个js文件,并且在body的onload时候触发调用一下main()函数就可以了。简单说一下这两个js文件,Helloworld.js是js的代码逻辑,而Helloworld-Shaders.js则是包装了一下着色器的代码,里面其实就是字符串;下面都会详细讲到的。

编写着色器代码

接下来就是WebGL编程的关键所在了,以后大部分的编程内容都是写在这里的,新建一个Helloworld-Shaders.js文件,然后编写以下内容:

//顶点着色器代码
var VERT_SHADER_SRC = `
void main()     
{                                   
   gl_Position = vec4(0.0, 0.0, 0.0, 1.0);
   gl_PointSize = 50.0;
}
`;
//片元着色器代码
var FRAG_SHADER_SRC = `
void main()
{
    gl_FragColor=vec4(1.0, 0.0, 0.0, 1.0);
}
`;

可以看到,这其实就是js包了两个字符串,当然也是可以纯文本文件,然后XMLHTTPRequest来读取文件,不过这是题外话了,而且意义不是很大。这两个着色器的代码非常简单,和第一弹让大家感受的不太一样,缺少了不少东西,确实少了很多,但是这不会影响啥,反而没有那些东西更好理解主体流程。

顶点着色器

先看顶点着色器的代码,就像c语言一样,有一个main()函数,没有使用到第一弹看到的其他传入的变量,仅使用了两个gl_xxx内置变量,其中gl_Position就是该顶点的坐标,它必须设置,如果不设置的话是不会有任何东西显示出来的,它的类型是vec4,是不是很奇怪为什么不是用三维的坐标vec3?其实第四维的值是1.0,这是数学上的齐次坐标,(x, y, z, w)就等价于(x/w, y/w, z/w),可以回去温故知新一下高数咯,为啥这里要用齐次坐标呢,主要是未来后续的计算方便,后面的各种变换都会用到矩阵运算,到时候就会感受到了。

gl_PointSize就是该顶点的大小,就是多少个像素,它不是必须的,如果不设置的话,就是默认1.0。注意到的是它们的类型都是float类型的,如果类型写错是不行的,着色语言是强类型语言。

从第一弹我们理解到,我们需要绘制的图形的每一个顶点都会经过顶点着色器进行处理转换,最终产生纹理坐标,而这里我们并没有需要接收图形的顶点进行转换,而仅仅是指定了一个中心的坐标点进行绘制。

片元着色器

再看片元着色器,核心的就是给gl_FragColor赋值,它也是一个内置变量,也是唯一的输出变量,从第一弹了解到,光栅化后的每个片元都会执行一次片元着色器,可以理解为每个像素都执行一次,而这里的例子也就是绘制一个像素。很明显,gl_FragColor就是一个四维向量,之前我们了解到一个像素就是RGBA四个字节,正好需要四个向量来表示,不一样的是,一个字节的取值范围是0-255,为啥这里的值却不是呢?这是因为GL里面把这些取值范围通通都做了归一化的处理,学过数学的都知道这是啥,小学就知道什么是“单位一”了,大学过了也该知道归一化,这样处理过后,一切的处理都会简单起来了,这也会带来一个精度的问题,一般就是float精度即可。后续学习关于纹理尺寸、坐标系等等,都会涉及到归一化的呢。

各位可以去修改一下上面的值看看啥效果变化。

编写JS代码

主要的流程代码逻辑是在Helloworld.js文件里面,我们新建一个js文件,编写以下代码:

//通过canvas获取gl context,可以传入额外参数
//兼容几种浏览器的获取方式
function getWebGLContext(canvas, opt_attribs) {
  var names = ["webgl", "experimental-webgl", "webkit-3d", "moz-webgl"];
  var context = null;
  for (var i = 0; i < names.length; ++i) {
    try {
      context = canvas.getContext(names[i], opt_attribs);
    } catch(e) {}
    if (context) {
      break;
    }
  }
  return context;
}

//初始化着色器,传入GL contest、顶点着色器代码、片元着色器代码
function initShaders(gl, vshader, fshader) {
        //创建着色程序,实际上返回的int值,相当于底层的一个句柄引用
        var program = createProgram(gl, vshader, fshader);
    if (!program) {
        console.log('Failed to create program');
        return false;
    }
    //指定这个gl context使用这个着色程序
    gl.useProgram(program);
    gl.program = program;
    return true;
}

//创建着色程序
function createProgram(gl, vshader, fshader) {
        //分别编译加载顶点着色器和片元着色器代码,实际上返回的也是int类型
    var vertexShader = loadShader(gl, gl.VERTEX_SHADER, vshader);
    var fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fshader);
    if (!vertexShader || !fragmentShader) {
        return null;
    }
    //首先创建一个程序,获取这个程序的句柄引用
    var program = gl.createProgram();
    if (!program) {
        return null;
    }
    //然后把这个程序绑定着色器
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    //链接程序,是不是和c语言的编译很像?
    gl.linkProgram(program);

    //获取program的链接情况,如果链接失败则进行清理
        var linked = gl.getProgramParameter(program, gl.LINK_STATUS);
    if (!linked) {
        var error = gl.getProgramInfoLog(program);
        console.log('Failed to link program: ' + error);
        gl.deleteProgram(program);
        gl.deleteShader(fragmentShader);
        gl.deleteShader(vertexShader);
        return null;
    }
    return program;
}

//加载编译着色器代码
function loadShader(gl, type, source) {
        //创建一个新的着色器
    var shader = gl.createShader(type);
    if (shader == null) {
        console.log('unable to create shader');
        return null;
    }
    ///加载着色器的源代码
    gl.shaderSource(shader, source);
    //编译着色器代码
    gl.compileShader(shader);
        //获取着色器的编译情况,如果编译失败则进行处理
    var compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
    if (!compiled) {
        var error = gl.getShaderInfoLog(shader);
        console.log('Failed to compile shader: ' + error);
        gl.deleteShader(shader);
        return null;
    }
    return shader;
}

//主程序入口
function main() {
    //获取<canvas>标签
    var canvas = document.getElementById('webgl');
    if(!canvas) {
        console.log('Failed to retrieve the <canvas> element');
        return;
    }
    //获取web gl context
    var gl = getWebGLContext(canvas);
    if(!gl) {
        console.log('Failed to get the rendering context for WebGL');
        return;
    }

    //初始化着色器
  if (!initShaders(gl, VERT_SHADER_SRC, FRAG_SHADER_SRC)) {
    console.log('Failed to intialize shaders.');
    return;
  }

    //设置canvas的背景颜色
    gl.clearColor(0.0, 1.0, 0.0, 1.0);
    //清空颜色缓冲区
    gl.clear(gl.COLOR_BUFFER_BIT);

    //画上一个点
    gl.drawArrays(gl.POINTS, 0, 1);
}

上面的代码几乎每一行都有了代码注释,不过我还是想要详细解释一下,这是最核心的地方了。

执行流程

看到main()函数,执行流程可以归纳为以下几步:

image.png

  • 第一步不需要过多解释了,在WebGL是作用于Canvas的,肯定需要获取元素的;
  • 第二步调用的getWebGLContext()函数其实也是调用了canvas.getContext()方法,不过是可能在不同的浏览器提供的名字不太一样,这里的context是很重要的概念,一个GL的context对应的是一个GL指令执行队列,如果不同的context混用的话,可能会出现画面断裂的情况,后续如果遇到再说;
  • 第三步是初始化着色器了,下面详细说;整个流程的本质其实就是浏览器加载了JS代码丢给JS引擎去执行,而JS代码则是加载了着色器的代码,然后丢给了WebGL系统去执行;
  • 第四步是设置canvas的背景色,调用的是clearColor()方法,可以理解为每次绘制的时候把画布清掉并填充上一个颜色,另外关于归一化的问题上面已经说到啦。
  • 第五步就是清除颜色缓冲区,这个概念太复杂了,还会有深度缓冲区,和模板缓冲区,我知道的深度缓冲区其实是跟三维的绘制相关,而模板缓冲区就不太懂了;可以理解颜色缓冲区就是显示绘图的一个缓冲区,这个缓冲区的内容最终会展示在屏幕上,如果在每次刷新的时候不清空一下,下次显示就会有重影了。
  • 第六步的绘图其实就是调用了drawArrays()函数,第一个值,指定的是绘制一个点,第二个值是告诉从哪个点开始绘制,第三个参数是告诉绘制几个点,后续就会有了解到了。

这一个流程下来,就可以绘制了上面那个点了,也就是说这个点都会经过上面所写的着色器代码去执行。

初始化着色器流程

回想一下第一弹里面介绍的OpenGLES的渲染管线2.0版本,在如此复杂的管线当中有两个着色器的地方便于程序员去开发代码,我们也了解了着色器代码的语法如何去编写了,那么该如何把编写好的着色器代码放到管线里面去执行呢?上面了解到,通过canvas拿到了WebGL的context了,如何用它来初始化着色器使之执行呢?

image.png

  • 第一步是创建一个着色器,调用的是createShader()方法,它接受一个常量类型参数,VERTEX_SHADER或者是FRAGMENT_SHADER,分别是顶点着色器或者片元着色器;需要注意的是返回值,OpenGL底层的规范都是c语言,所以所有的api返回通常都是int类型,可以理解为底层的一个句柄引用,并没有实际的对象。
  • 第二步就是用这个着色器来加载源码了,调用的是shaderSource()方法,第一个参数是第一步的返回,第二个参数是源码字符串。
  • 第三步就是编译着色器源码了,调用的是compileShader(),传入的是第一步返回的着色器。
  • 第四步是创建一个管线执行的程序,调用的是createProgram(),可以理解为,既然管线开发给程序员编程了,那么就肯定有程序的概念。
  • 第五步就是把创建的管线程序来附属上编译好的着色器,调用的是attachShader(),需要调用两次,因为有两个着色器。
  • 第六步就是链接管线程序,调用的是linkProgram(),可以像学习c语言的时候需要链接库一样去理解就好了。
  • 第七步就是在这个gl context中使用这个管线程序,调用useProgram()。

到此为止,一个完整的WebGL开发流程就跑完了,从最简单的html、js开始,到像素如何跑到顶点着色、片元着色器去执行的,基本上都理解了这个开发过程的每一个环节了,虽然扩展的内容不多,贵在理解为主,以不至于从入门就放弃,对WebGL望而却步就好。

总结

这一弹我就先学习理解这些内容了,弥补了一下第一弹的入门例子HelloWorld,例子真的很简单了,没有实现什么有价值的内容,如果是拿来主义者过来可能要失望了,我是希望通过一个像素点的绘制来理解第一弹了解到的渲染管线,大体知道一下这个WebGL编程是个怎么回事,麻雀虽小五脏俱全呢。

后续再争取利用业余时间一起学习交流更加深入的内容,主要还是因为平时上班真的忙啊,也要争取时间运动健身,毕竟我们是要花一辈子时间去写代码的,而不是35岁就退休了的呢,所以身体是革命的本钱啊,另外,业余时间还是尽量和女票玩耍比较重要,实在肝不出太多了;第二呢就还是那句话,我的学习以理解和培养兴趣为主,循序渐进,一步一个脚印的往前走,一定要有自己的理解。