WebGL概述——原理篇

6,175 阅读10分钟

前言

本文主要对WebGL进行一个概述,主要会涉及以下几个方面:

  1. GPU vs CPU
  2. Canvas2D与WebGL绘图的区别
  3. 渲染管线
  4. 着色器概述
  5. GPU与CPU的数据交换

本文不会涉及到具体的代码。

CPU vs GPU

CPU 和 GPU 都属于处理单元,但是结构不同。形象点来说,CPU 就像个大的工业管道,等待处理的任务只能依次的通过这跟管道,所以CPU处理这些任务的速度完全取决于处理单个任务的时间。

CPU管道虽然只能让任务一个一个依次执行,但是CPU处理单个任务的能力十分的强大,这样的特性让CPU处理一些大型任务时是足够了。但是处理图像却显得力不从心了,因为通常处理图像的逻辑并不是很复杂,另一方面,一幅图像是由成千上万的像素点组成,我们每次处理一个像素都是一个任务,让这么多的小任务依次通过我们的CPU管道,有点大马拉小车的味道了,此时,就需要我们的GPU登场了。

GPU 是由大量的小型处理单元构成的,它可能远远没有 CPU 那么强大,但胜在数量众多,可以保证每个单元处理一个简单的任务。GPU能够保证同时处理所有的像素点。如果要进行一个比喻的话,GPU处理的过程类似于我们祖宗发明的“活字印刷术”,将所有的字一次性排好,然后直接印在纸上,“印”这个动作就是GPU进行的过程。

Canvas2D vs WebGL

Canvas2D

接触过canvas绘图的同学一定对以下的绘图方式不会感到陌生

const ctx = canvas.getContext('2d');
function ctxDraw() {
    ctx.fillStyle = '#f60';
    ctx.fillRect((width - rectWidth) / 2, (height - rectHeight) / 2, rectWidth, rectHeight);
}

我们先修改了填充颜色,然后调用fillRect来绘制了一个矩形。这是命令式、过程式的。

WebGL

我们又如何使用WebGL绘制一个矩形呢,我们看以下示例代码


function glDraw() {
    const vertexShader = `
        precision mediump float;
        attribute vec4 a_position;
        void main () {
            gl_Position =  a_position;
        }
    `;

    const fragmentShader = `
        precision mediump float;
        void main () {
            gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0);
        }
    `;
    let a_positionLocation;
    let program = util.initWebGL(gl, vertexShader, fragmentShader);
    gl.linkProgram(program);
    gl.useProgram(program);

    let points = new Float32Array([
        -0.5,-0.5,
        0.5,-0.5,
        0.5,0.5,
        0.5,0.5,
        -0.5,0.5,
        -0.5,-0.5,
    ]);

    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);
    a_positionLocation = gl.getAttribLocation(program, "a_position");
    gl.enableVertexAttribArray(a_positionLocation);
    gl.vertexAttribPointer(a_positionLocation, 2, gl.FLOAT, false, 0, 0);

    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.drawArrays(gl.TRIANGLES, 0, 6);
}

绘制一个矩形,使用WebGL就需要编写这么多的代码,这还不包括一些工具方法的代码。那么,WebGL到底是采用了一种什么样的方式来绘制图形的呢?

我们可以想象WebGL就是一个巨大的电路,我们可以自定义这个电路中一些电线的走向,或者是给这个电路中添加一些元器件等等,然后我们只需要按下启动开关,这个电路就能够自己运作。

渲染管线

我们现在就来探索这个电路到底是怎么样运作的吧,这里我们将这个电路的这一整套的运作流程成为称为“渲染管线”

如上图所示,渲染管线主要分为以下几步:

  1. 顶点着色器处理顶点
  2. 图元装配
  3. 光栅化
  4. 片元着色器着色
  5. 测试 & 混合

我们现在依次对每个步骤进行详细的说明

顶点着色器处理顶点

这里,我们说明一下“着色器”的意思是什么,你可以简单的把“着色器”这三个字理解为一段程序而已。只是它的名字略微不同罢了。

在顶点着色器中,我们会对传入GPU中的顶点信息进行处理(比如,我们绘制上面的矩形时,我们通过 bufferData这个方法往WebGL中传入了顶点数据),我们可能需要进行一些裁剪空间变换、平移、缩放、旋转等操作。这些操作都是对顶点进行的,它直接改变了顶点的位置。

图元装配

通过顶点着色器的处理,我们得到了我们想要的顶点位置,假设我们现在得到了矩形的4个点的位置(实际上我们传入了6个点)。现在这一步,我们需要告诉GPU如何将这几个点以什么样的形式将这6个点组合起来(哪几个点为一组),这里我们选择每3个为一组,每一组表示一个三角形。将顶点装配成基本图形的过程就称为图元装配(WebGL能够装配的基本图形只有:点、线、三角形)

光栅化

上一步中,我们告诉了GPU如何去组装我们的顶点。目前为止,我们依然还是只有6个顶点的信息和装配的方式,但是我们如何使用这6个点和装配的方式将矩形表示在屏幕上呢?这就是光栅化的过程。一种简单的光栅化的方式就是:

遍历所有的像素为止,依次判断她们是否落入了我们刚刚组装的图形内,如果在图形内,则对该像素进行下一步操作(着色)。

除了判断是否在图形内的操作,还会对非顶点的位置进行插值处理,赋予每个像素其他的信息,因为一个像素不仅仅只有颜色信息,所以我们称其为“片元”。

片元着色器着色

在光栅化的过程中,我们判断了哪些片元落在了我们的图形内,我们现在只需要对这些片元进行着色处理即可。最简单的着色方式就是直接设置一个颜色就可以了。 当然,片元着色器也可以很复杂,比如光照、材质等基本都是在片元着色器中进行完成的。

测试 & 混合

这里简单的讲一下深度测试,除了深度测试还有模版测试等。深度测试就是说,因为在WebGL中可以绘制多个物体,这些物体之间是有层级关系的,通过深度测试后,有的物体被遮挡的部分则不会被显示出来。

混合就是透明度值的混合,我们可以设置不同的混合方法以达到不同的效果。

在上述过程中,顶点着色器处理顶点片元着色器着色这两个过程一般都是可编程的,也就是我们所说的shader程序。接下来,我们大概介绍一下shader

着色器(Shader)概述

着色器是一种计算程序,主要用于进行图形处理。

顶点着色器(Vertex Shader)

在顶点着色器中,主要是对顶点及其中包含的一些数据进行处理。着色器中包含了一些内置的变量,比gl_Position

// 这里的a_position是可以在js运行运行时中获取到的变量,通过它可以往WebGL中传递数据
attribute vec4 a_position;
uniform mat4 u_matrix;
void main () {
	// gl_Position是GLSL内置的变量
	// 将a_position的值赋给gl_Position,gl_Position的值会进入图形渲染管线的下一阶段
    // u_matrix 与 a_position相乘,这里是矩阵与向量相乘,结果仍然为一个向量
	gl_Position = u_matrix * a_position;
}

片元着色器(Fragment Shader)

片元着色器的主要功能就是着色,我们可以通过一系列的程序处理给每个片元指定不同的颜色。如下程序所示:

precision mediump float;
void main () {
	gl_FragColor = vec4(1.0, 1.0, 0.0, 1.0);
}

这段代码可以这样理解:

for (let y = 0; y < height; y++){
	for (let x = 0; x < width; x++) {
    	// 双重循环里面的部分就相当于片元着色器中的内容
        const offset = (y * width + x) * 4;
    	data[offset + 0] = 255;
        data[offset + 1] = 255;
        data[offset + 2] = 0;
        data[offset + 3] = 255; 
    }
}

我们需要着重理解,片元着色器对于每个片元都是同时执行的,片元之间不存在相互依赖的关系。

存储限定符

在上面的程序中,你可能注意到了attribute, uniform等关键字,它们被称为存储限定符。

  • attribute: 只能出现在顶点着色器中,表示每个顶点的数据。在光栅化过程中会对attribute变量进行插值处理。可以从外部往WebGL内部中传递数据
  • uniform: 可以出现在顶点着色器和片元着色器中,表示统一的值,每个顶点/片元使用的这个值都是一样的。
  • varying: 可以出现在顶点着色器和片元着色器中,表示变化的值,在光栅化阶段,GPU将attribute变量插值处理后的结果赋给了varying变量,它是链接顶点着色器和片元着色器变量之间的桥梁。

数据传递

那我们如何在js运行时中往WebGL中传递数据呢?我们主要分为几个部分:

  1. 传递attribute变量的数据(一般是顶点信息)
  2. 传递一般的uniform变量数据(整数、浮点数、向量、矩阵)(一般是一些辅助信息,比如时间信息,某段程序需要根据时间的变化来计算最后的值)
  3. 传递纹理

传递Attribute变量

传递attribute变量的数据需要使用 WebGLBuffer这个WebGL内置的数据结构。

步骤:

  1. 创建WebGLBuffer
  2. 绑定Buffer到ARRAY_BUFFER(gl.bindBuffer())
  3. 传入数据

这里ARRAY_BUFFER充当了一个桥梁的作用,我们其实是将数据传到了ARRAY_BUFFER,ARRAY_BUFFER在上一步已经与我们创建好的WebGLBuffer绑定在了一起了。所以数据直接写入了WebGLBuffer。

传递Uniform变量

传递uniform类型的变量的步骤就比较简单了。步骤如下:

  1. 通过API获取uniform变量在WebGL程序中的地址(gl.getUniformLocation)
  2. 再通过API这个地址中填充数据即可(gl.uniform1f, gl.uniform1i, gl.uniform2f......)

传递纹理

首先,我们需要搞懂纹理是什么,简单的讲,纹理就是一张图片。在Web世界中,纹理可以是 <img/> <video/> <canvas/>标签,也可以是ImageBitmap和TypedArray对象。

传递纹理与传递attribute变量类似,这里我们不使用WebGLBuffer,而是使用WebGLTexture对象,并且需要对WebGLTexture对象设置相应的参数。

步骤:

  1. 创建纹理对象(WebGLTexture)(gl.createTexture())
  2. 绑定纹理对象(gl.bindTexture)
  3. 设置纹理参数
  4. 传入纹理(gl.texImage2D)

这里我们也可以发现,gl.TEXTURE_2D同样充当了桥梁的作用,我们直接操作的都是gl.TEXTURE_2D,只不过我们已经提前将纹理对象与TEXTURE_2D绑定在一起了,相当于间接的操作了WebGLTexture对象了。

下图简要的描述了js是如何往WebGL中传递数据的。

还记得我们之前将WebGL比喻成一个巨大的电路图吗?往WebGL中填充数据就是在给这个电路增加电气元件(WebGLTexture, WebGLBuffer),我们往其中填充数据,改变WebGLBuffer/ARRAY_BUFFER, WebGLTexture/TEXTURE_2D 之间的绑定关系,其实就是在修改电路中电线的连接方式。当这一切就绪时,gl.drawArrays这个API就仿佛是一个开关,调用这个函数整个电路就会自动运行起来。

总结

本文讲述了WebGL中一些比较重要的概念。我们首先介绍了CPU与GPU处理数据之间的差异,CPU是依次执行任务的,是有前后顺序的。GPU处理数据是并行的,能够同时处理大量的任务,但是每个处理核心的运算能力不强。

然后介绍了Canvas2D与WebGL之间绘图的差异,Canvas2D绘图是命令式的,WebGL绘图是一种“链接式”的过程,你可以想象WebGL是一张巨大的电路,我们提前布置好电路与其中的电气元件,一旦按下开关,这个电路就会自动执行。

后续介绍的渲染管线和着色器的知识你可能会感觉到陌生,不过没关系,随着你学习的深入你会发现一切都是那么的自然。后续也会推出实战篇的文章。敬请期待。