【零基础】充分理解WebGL(一)

avatar
掘金前首席打杂官

在前端方向里,WebGL算是典型的小众领域,真正理解的人并不多。甚至一些拿Three.js写3D应用的同学,对WebGL本身也是一知半解。

之所以这样,是因为WebGL的技术栈和传统的Web前端技术有极大的差别。相对而言,传统Web前端使用的API比较高级,不存在太多需要理解的底层原理合概念,而WebGL的核心是OpenGL,它是OpenGL在Web上的实现。OpenGL是通过操作GPU来完成图形绘制渲染的,因此它的API相对比较底层,使用起来较为繁琐,这使得一些习惯于前端开发的工程师很难适应,所以就会觉得学习门槛较高。

实际上,要理解和学会WebGL,并没有那么困难,我们只需要理解一下GPU,了解它与CPU的不同点,然后再理解运行GPU代码的语言——glsl,了解着色器的基本概念和用法,就可以轻松理解WebGL的本质原理,然后在花一点时间和耐心,慢慢学习WebGL的API,就可以掌握WebGL这门技术了。

那么什么是GPU,它与CPU有什么不同?我们通过下面几张图来看一下:

t0149eca869be61bc44.webp

上面这张图,是CPU的工作原理,它就像是一个管道,数据(图中的箱子)从左侧输入,在CPU中完成处理,然后从右侧输出。CPU是由多个这样的管道构成的,每个管道我们叫做一个CPU内核,如果你打开你电脑的操作系统,查看本机信息,你可能会看到类似这样的信息:2.6 GHz 六核Intel Core i7,这里的六核,你可以理解成有6个这样的管道,因此可以同时处理6个任务。

CPU的工作能力,与管道本身的处理速度(频率)合管道的数量(内核数)有关系,频率越高,那么运算处理单一任务的速度就越快,内核数越多,那么能同时并行处理的任务数就越多。

虽然现代计算机的CPU运算能力很强,但是它也有局限性,对于某些场景,它并不擅长,比如图形渲染

我们知道,计算机图像是由像素构成,所谓像素,可以简单理解为最终呈现在显示设备上的一个1x1的颜色小方块。

Licorne_Pixel_Art_Dab_large.webp

现在的显示设备非常先进,可以用非常多的像素小方块来精确构图。前端的CSS中的px单位,就是像素单位,一张800px长、600px宽的图片,逻辑上是由600*800,也就是48万个像素点构成的。如果要对这张图片的像素进行计算,用CPU来运算的话,单核CPU需要处理48万个微小任务,就像下面这张图:

t01f6e6963ceec21073.webp

不是说CPU不能完成这样的处理,每一个像素的计算可能是非常简单的(只是处理一下颜色),但是数量太多,对CPU这样的结构也会造成负担。因此,在这个时候,另外一种高并发结构,也就是GPU就登场了。

t01d1ab4a0e55fa8cac.webp

与CPU不同,GPU可以看成是由数量非常多的微小管道构成的结构,每一个管道恰好可以处理“一粒沙子”,这样,如果对于一张600像素x800像素的图片,有48万个管道组成的GPU,就可以同时处理这48万个像素点了!事实上,GPU几乎就是这样做的。

flowchart LR
准备数据 --> 着色处理 --> 帧缓冲 --> 渲染输出 

WebGL利用JavaScript来准备数据,将数据通过共享数据结构(ArrayBuffer) 传递给GPU,由GPU进行着色处理,然后再将处理后的数据输出到帧缓冲区,最后再渲染出来。

这其中的关键是头两个步骤,也就是准备数据和着色处理,其中准备数据一般是通过JavaScript的类型数组(TypedArray),着色处理是通过WebGL Program执行一种特殊的glsl语言来实现。在WebGL中,着色阶段通常分成两步,分别是顶点着色和片段着色。

我前面也说过,WebGL的API比较底层,所以操作起来比较繁琐,但是也有许多JS库,帮我们封装了基本的操作,因此在这里,我们可以先跳过繁琐的API部分,利用我简单封装的一个开源库gl-renderer来学习一下WebGL的数据和着色部分。

两个着色器

动手实践是最好的理解问题的方法之一,所以我们来动手写一写代码。

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas, {webgl2: true});

const fragment = `#version 300 es
precision highp float;
out vec4 FragColor;
void main() {
  FragColor = vec4(1, 0, 0, 1);
}
`;
const program = renderer.compileSync(fragment);
renderer.useProgram(program);
renderer.render();

code.juejin.cn/pen/7098235…

上面的代码里面,JS的部分很好理解,但是其中有一段fragment变量中的字符串,是需要我们关注的部分,没错,它就是两个着色器之一的片段着色器代码。

这段代码是用glsl语言写的,但是并不难理解,第一句 #version 300 es 声明这个着色器是webgl 2.0版本的着色器,浏览器目前同时支持webgl 1.0和webgl 2.0两个版本,它们有一些差异,但差异不是很大。

第二句precision highp float;表示设置浮点数精度为高精度,这个可以暂时忽略,以后系列文章中再详细讲解。

第三句out vec4 FragColor 声明FragColor是输出变量,它的类型是vec4,是一个四维向量,用来表示一个RGBA颜色值,它与CSS的颜色区别是,CSS的RGB值是0到255,Alpha值是0到1,但是在着色器里面,RGBA的值都是从0到1。

void main是主函数,vec4(1, 0, 0, 1) 表示将 FragColor 设置为红色。

👉🏻 你可以试着修改码上掘金中的代码,把 vec4(1, 0, 0, 1) 改成 vec4(0, 1, 0, 1),看看会发生什么?

在这里有必要解释一下为什么这么设置整个画布会变成红色。还记得前面说的,GPU是并行计算的,也就是说,这段着色器代码,是每个像素都并行执行(严格来说是根据图元装配的结果来执行对应区域的像素,但在这里我们先略过这一点),因此,整个画布中,所有的像素点,你可以认为都执行了一遍上面的着色器代码,而且是同时、并行执行的,由于是无差别执行的设置颜色为红色,因此整块画布都成为了红色的。

我们可以修改一下上面例子的代码,看一下给每个像素设置不同颜色的情况:

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas, {webgl2: true});

const fragment = `#version 300 es
precision highp float;
out vec4 FragColor;
uniform vec2 resolution;
void main() {
  vec2 st = gl_FragCoord.xy / resolution;
  FragColor = vec4(st, 0, 1);
}
`;
const program = renderer.compileSync(fragment);
renderer.useProgram(program);
renderer.uniforms.resolution = [canvas.width, canvas.height];
renderer.render();

我们修改了上面的代码,在着色器中增加了一个uniform vec2 resolution,这是声明了一个resolution变量,它的类型是二维向量,我们通过renderer.uniforms.resolution将画布的宽高传入。

gl_FragCoord.xy是一个内置变量,它表示当前渲染的像素在画布内的坐标,左下角是[0,0],右上角是[width,height],所以gl_FragCoord.xy / resolution可以将坐标值“归一”(即将值限制到0~1区间,这是一种在写着色器的时候经常使用的数学技巧)。然后我们将st的值传给FragColor,这样最终运行的结果如下:

code.juejin.cn/pen/7098245…

通过这个例子,我们可以简单理解片段着色器的运行方式,片段着色器对图形十分重要,在后续的文章里,我们还有很多技巧需要逐步学习。

但是我们现在先回过头来,解决一个问题,为什么这里片段着色器对整个canvas生效?

所以,接下来我们就来了解另一个着色器:顶点着色器。

还是老办法,动手实践——让我们来修改代码:

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas, {webgl2: true});

const fragment = `#version 300 es
precision highp float;
out vec4 FragColor;
void main() {
  FragColor = vec4(1, 0, 0, 1);
}
`;
const program = renderer.compileSync(fragment);
renderer.useProgram(program);
renderer.setMeshData([
  {
    positions: [[0, 1, 0], [-1, -1, 0], [1, -1, 0]],
    cells: [[0, 1, 2]],
  },
]);
renderer.render();

code.juejin.cn/pen/7098248…

在这里,我们没有添加顶点着色器,只是给renderer设置了一些数据,在数据里我们指定了三个顶点,它们的三维坐标分别是[0, 1, 0], [-1, -1, 0], [1, -1, 0],这里我们没有用到z轴,所以z保持0,x和y在WebGL中,默认范围是从-1到1,所以WebGL的平面坐标系原点在中心,左下角是[-1,-1],右下角是[1, -1],右上角是[1, 1],左上角是[-1,1]。我们设置了position这三个顶点之后,绘制在画面上的就变成了一个三角形。

之所以我们没有指定顶点着色器,是因为gl-renderer有默认的顶点着色器,代码如下:

#version 300 es
precision highp float;
precision highp int;

in vec3 a_vertexPosition;

void main() {
  gl_PointSize = 1.0;
  gl_Position = vec4(a_vertexPosition, 1);
}

我们也可以指定顶点着色器,我们可以继续修改代码:

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas, {webgl2: true});

const vertex = `#version 300 es
precision highp float;
precision highp int;

in vec3 a_vertexPosition;

void main() {
  gl_PointSize = 1.0;
  gl_Position = vec4(0.5 * a_vertexPosition, 1);
}`;

const fragment = `#version 300 es
precision highp float;
out vec4 FragColor;
void main() {
  FragColor = vec4(1, 0, 0, 1);
}
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
renderer.setMeshData([
  {
    positions: [[0, 1, 0], [-1, -1, 0], [1, -1, 0]],
    cells: [[0, 1, 2]],
  },
]);
renderer.render();

code.juejin.cn/pen/7098253…

上面的代码我们指定了顶点着色器,在它里面我们把a_vertexPosition,也就是我们传入的顶点坐标给乘以了0.5,所以最终绘制出来的三角形周长就是原来的1/2。

为什么是三角形?

因为三角形是WebGL的基本图元,WebGL支持点、线、三角形等基本图元。下面的代码我们更换了图元。

const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas, {webgl2: true});

const vertex = `#version 300 es
precision highp float;
precision highp int;

in vec3 a_vertexPosition;

void main() {
  gl_PointSize = 1.0;
  gl_Position = vec4(0.5 * a_vertexPosition, 1);
}`;

const fragment = `#version 300 es
precision highp float;
out vec4 FragColor;
void main() {
  FragColor = vec4(1, 0, 0, 1);
}
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
renderer.setMeshData([
  {
    mode: 'LINE_STRIP',
    positions: [[0, 1, 0], [-1, -1, 0], [1, -1, 0]],
    cells: [[0, 1, 2, 0]],
  },
]);
renderer.render();

code.juejin.cn/pen/7098254…

最后剩下一个问题,我们默认不设置顶点的时候,绘制的图形是整个canvas范围,但是WebGL并不支持四边形图元,那么我们原本的绘制范围是如何界定的呢?

我把这个问题作为本篇文章的课后问题,留给下一讲来解决吧。

以上内容有任何问题,欢迎在评论区讨论。