零基础玩转 WebGL - 着色器

5,068 阅读15分钟

这是《零基础玩转 WebGL》系列教程的第二篇文章,上一篇文章 零基础玩转 WebGL - 快速上手

上一篇文章,快速入门了 WebGL,但是对 WebGL 中的着色器比较陌生,这篇文章将详细讲解 WebGL1 中的着色器。

WebGL 基于 OpenGL,是 OpenGL 的子集。OpenGL 中的着色器是使用 GLSL 编写的,所以 WebGL 中也是使用 GLSL 编写着色器,WebGL1 中使用的 GLSL 1.00 版本。

GLSL(OpenGL Shading Language)是 OpenGL 着色器语言,是一个以 C 语言为基础的高阶着色语言。它可以提供开发者对渲染管线更多的控制,而无需使用汇编语言或硬件规格语言。

上一篇文章介绍了 WebGL 程序执行主要分为两个阶段,CPU 阶段和 GPU 阶段,在 CPU 中我们可以直接使用 JS 来编写代码,但是如果要控制 GPU 的渲染逻辑就需要使用着色器,也就是使用 GLSL 编写的着色器程序。

GPU 中的处理过程大致如下图,其中蓝色部分是我们可以控制的(忽略几何着色器,WebGL中没有)。

image.png

之前有介绍过,WebGL 只能渲染点、线和三角形,那些复杂的 3D 模型都是一个个三角形组成的。一个三角形是由 3 个顶点组成,如果我们想渲染两个三角形,就提供 6 个顶点,WebGL 每处理完 3 个顶点后会将这三个顶点连接成一个三角形。

WebGL 中一共有两种类型的着色器,分别是顶点着色器和片段着色器,一个处理每个顶点位置,一个处理每个片段的颜色。

顶点着色器

顶点着色器(vertex shader)用来处理每个顶点,计算出每个顶点的位置,比如要渲染一个三角形,它是由 3 个顶点组成的,那么顶点着色器就会运行 3 次,分别处理 3 个顶点的位置。

下面是一个最简单的顶点着色器。

attribute vec4 a_position;

void main() {
  gl_Position = a_position;
}

WebGL 会默认执行着色器中的 main 函数,顶点着色器中我们使用 attribute 存储限定字获取外部传入的顶点信息。然后使用 gl_Position (内置变量)输入处理过的顶点位置(比如在顶点着色器中将顶点左移 10px)。

顶点着色器中要计算顶点的坐标,计算好的坐标并不是在 main 函数中直接返回,而是赋值给内置的 gl_Position 变量。上面例子中我们直接 JS 中传递过来的数据作为顶点位置。(如果不清楚数据怎么传递的请查看上篇文章

我们可以使用下面的伪代码表示顶点着色器是如何被执行的。

const points = [
  0, 0.5,
  0.5, 0,
  -0.5, -0.5
]
// 三角形的三个顶点,在 CPU 中提供的数据

const vertex = points.map(p => vertexShader(p))
// 获得最终的顶点位置

如上图所示,我们从外部输入顶点坐标,然后在顶点着色器中对它进行矩阵运算,然后通过 gl_Position 变量输出新的坐标。(矩阵变换请查看后续文章)

attribute 存储限定字

上面例子中有一个 attribute 关键字,它只能用在顶点着色器,被用来表示逐顶点信息,一般用来传递顶点位置、顶点颜色等信息。我们可以通过它来获取外部传递过来的信息。上面例子中,我们定义了三个顶点传递给 a_position 变量,顶点着色器不是一次性获取到这些顶点,而是一个个的获取。

上篇文章中我们是这样向顶点着色器传递数据的。

const positionLocation = gl.getAttribLocation(program, 'a_position')
const positionBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
    0, 0.5,
    0.5, 0,
    -0.5, -0.5
]), gl.STATIC_DRAW)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)

我们首先获取变量 a_position 的位置。由于我们需要传递一堆的顶点,所以这里创建一个 Buffer 来存放这些顶点。我们还需要使用 vertexAttribPointer 告诉 WebGL 如何获取 Buffer 中的数据。

片段着色器

片段着色器(fragment shader)也叫片元着色器,在顶点着色器处理完顶点后,WebGL 还会把这些三角形进行插值,将三角形变成一个个像素,然后对每个像素执行一次片段着色器,片段着色器中使用 gl_FragColor 内置变量输出这个像素的颜色。

上篇文章中的片段着色器代码如下。

precision mediump float;
uniform vec4 u_color;

void main() {
  gl_FragColor = u_color;
}

uniform 存储限定字

我们先忽略比顶点着色器多出的一行代码 precision mediump float;。我们在片段着色器中使用了 uniform 存储限定字获取了外部传递的数据。

uniform 存储限定字,可以用在片段着色器也可以用在顶点着色器,它是全局的,在着色器程序中是独一无二的。

上篇文章我们是这样传递数据到片段着色器中。

const colorLocation = gl.getUniformLocation(program, 'u_color')
gl.uniform4f(colorLocation, 0.93, 0, 0.56, 1)

由于 uniform 是全局唯一的,相当于常量,所以不需要 Buffer,直接设置它的值就行了。uniform4f 方法后缀 4f 表示 4 个浮点数,在这里我们用来表示一个颜色值的 rgba

GLSL 语法

接下来将讲解 GLSL 语法,当然并不会一次性全部讲完,会先介绍最常用的一部分,后续文章再慢慢讲解,如果你想了解更多信息,请查看这个文档,www.khronos.org/registry/Op…

GLSL 它是强类型语言,每一句都必须有分号。它的语法和 typescript 还挺像。

GLSL 的注释语法和 JS 一样,变量名规则也和 JS 一样,不能使用关键字,保留字,不能以 gl_webgl__webgl_ 开头。运算符基本也和 JS 一样,++ -- += && || 还有三元运算符都支持。

GLSL 中主要有三种数据值类型,浮点数、整数和布尔。注意浮点数必须要带小数点。类型转换可以直接使用 floatintbool 函数。例如下面像下面这样将整数 1 转换成浮点数 1.0

float f = float(1);

另外 GLSL 中定义变量的语法是 <类型> <变量名>,并且赋给变量的值应该和定义变量的类型是一致的,如果上面代码这样写 float f = 1; 将会直接报错,因为 1 是整数,但是加一个小数点就会变成浮点数,float f = 1.; 这样写则不会报错。

矢量和矩阵

GLSL 中还支持矢量和矩阵类型(矢量和矩阵下篇文章将会讲解)。矢量可以理解为一个数组,矩阵可以理解为一个二维数组。

GLSL 中一共有如下类型矢量和矩阵。

vec2, vec3, vec4 // 分别是包含 2,3,4 个浮点数的矢量
ivec2, ivec3, ivec4 // 分别是包含 2,3,4 个整数的矢量
bvec2, bvec3, bvec4 // 分别是包含 2,3,4 个布尔值的矢量

mat2, mat3, mat4 // 分别是包含 2x2,3x3,4x4 浮点数元素的矩阵

例如上面例子中我们编写了 attribute vec4 a_position 这行代码,其中的 vec4 表示 a_position 是一个包含 4 个浮点数的变量。

我们给矢量赋值可以直接使用内置的和类型一样的构造函数。如下所示。

vec3 pos = vec3(1., 2., 3.0);

浮点数小数点后的 0 是可以省略的,上面写法有点像 JS 中的 let pos = [1, 2, 3],与 JS 不同的是 GLSL 中的写法非常灵活。如下所示。

vec3 a = vec3(1.); // 三个值都被设置为 1.,就和 vec3(1.,1.,1.) 一样
vec2 b = vec2(a); // 只是用 a 的前两个浮点数
vec4 c = vec4(b, a) // 还可以使用多个矢量合成一个矢量,类似与 JS 中的 [...b, ...a].slice(0,4)

如果要范围矢量中的值可以使用如下方式。

vec4 a = vec4(1.);

a.x // 访问第 1 个值
a.y // 访问第 2 个值
a.z // 访问第 3 个值
a.w // 访问第 4 个值

a.x // 访问第 1 个值
a.y // 访问第 2 个值
a.z // 访问第 3 个值
a.w // 访问第 4 个值

a.x // 访问第 1 个值
a.y // 访问第 2 个值
a.z // 访问第 3 个值
a.w // 访问第 4 个值

a.rgb // 混合访问,相当于 vec3(1.);
a.xx // 重复也可以,相当于 vec2(1.);

和矢量一样构造矩阵也是同样使用和类型名字一样的构造函数。

mat4 m4 = mat4(
1., 2., 3., 4.,
5., 6., 7., 8.,
9., 10., 11., 12.,
13., 14., 15., 16.
);

上面代码定义了一个 4x4 矩阵,类似于在 JS 中这样定义。

let m4 = [
[1., 2., 3., 4.],
[5., 6., 7., 8.],
[9., 10., 11., 12.],
[13., 14., 15., 16.]
]

另外 OpenGL 中的矩阵是列主序的,需要转置一下看起来才像一个正常的矩阵(后面文章会详细讲解)。

另外你还可以像下面这样快速创建矩阵。

vec2 a = vec2(1.);
vec2 b = vec2(1.);

mat2 m = mat(a, b); // 矢量也可以作为参数,类似于 JS 中的 [...] 数组展开。

访问矩阵中的数值就可以 JS 中访问二维数组的值一样。

vec2 a = vec2(1.);
mat2 m = mat(a, 1., 1.);

a[0][0]; // float, 第一行第一个元素
a[0]; // vec2,第一行

a[0].x; // 和矢量一样,还可以使用 xyz 这些属性。

矢量和矩阵的运算将在后续文章中讲解。

分支和循环

GLSL 中的分支和循环和 JS 中一样,如下所示。

if (true) {} else if (true) {} else {}
for (int i = 0; i < 3; i++) {
	continue; // 或 break
}

唯一的区别是 for 中的 i 要指定类型。

函数

GLSL 中定义函数语法如下。

<返回值类型> <函数名字> (<参数类型> 参数名1, <参数类型> 参数名2, ...) {
    函数体;
    return 返回值;
}

如果函数没有返回值,函数的返回类型就是 void

float add(float a, float b) {
    return a + b;
}

void main() {
    float c = add(1., 2.);
}

上面是一个简单的使用函数的例子。和 JS 不同是 GLSL 不允许递归调用,也就是函数不能调用自己。

另外和 c 一样,如果将函数定义在使用函数的下面,需要先声明函数。

float add(float,float);

void main() {
    float c = add(1.,2.);
}

float add(float a, float b) {
    return a + b;
}

函数参数还有 4 个限定词,分别如下。

  • in 向函数输入的值,函数中修改不会影响外部,默认
  • const in 向函数输入的值,函数中不能修改
  • out 在函数中被赋值,并被传出
  • inout 传入函数,并且在函数中可以被修改影响到外面的值。
void add(float a, float b, out float c) {
    c = a + b;
}

void main() {
     float c;
     add(1., 2., c);
}

上面例子中,函数不返回值,而是将结果写入外面传进来的变量。

精度限定字

精度限定字用来控制数值的精度,越高的精度也就意味着更慢的性能,所以我们要合理的控制程序的精度。GLSL 中有三种精度 highpmediumplowp,分别是高、中和低精度。

mediump float size; // 声明一个中精度浮点数
highp int len; // 声明一个高精度整数
lowp vec4 v; // 低精度矢量

这样一个一个的变量声明,非常麻烦,我们还可以一次性声明。

precision mediump float; // 浮点数全部使用中精度

GLSL 帮我们设置了一些默认变量精度。顶点着色器中 intfloat 都是 highp

片元着色器中 intmediumpfloat 没有定义。这也就是为什么上面片元着色器中第一行代码是 precision mediump float; 了,因为 OpenGL 没有设置默认值,所以必须得我们自己设置。

另外 sampler2DsamplerCube 都是 lowp(它们主要用来渲染图片,后面会详细讲解)。

立方体

学习了着色器相关知识,现在再来渲染一个 3D 立方体吧。

立方体一共有 6 个面,一个面可以用两个三角形组成,也就是一个立方体需要 12 个三角形,一共需要 36 个顶点!

然而实际上立方体其实只需要 8 个顶点就行了,因为一个顶点可以被多个面复用。在 WebGL 中其实也可以复用顶点,来节省内存。

const gl = createGl()

const program = createProgramFromSource(gl, `
attribute vec4 aPos;
attribute vec4 aColor;
varying vec4 vColor;

void main() {
  gl_Position = aPos;
  vColor = aColor;
}
`, `
precision mediump float;
varying vec4 vColor;

void main() {
  gl_FragColor = vColor;
}
`)

const 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,
  0.5,0.5,0.5, -0.5,0.5,0.5, -0.5,-0.5,0.5, 0.5,-0.5,0.5
  // 立方体的 8 个顶点
])
const colors = new Float32Array([
  1,0,0, 0,1,0, 0,0,1, 1,0,1,
  0,0,0, 0,0,0, 0,0,0, 0,0,0
  // 每个顶点的颜色
])
const indices = new Uint8Array([ // 面的索引,值是 points 的下标
  0, 1, 2, 0, 2, 3, // 前
  1, 4, 2, 4, 7, 2, // 右
  4, 5, 6, 4, 6, 7, // 后
  5, 3, 6, 5, 0, 3, // 左
  0, 5, 4, 0, 4, 1, // 上
  7, 6, 3, 7, 3, 2  // 下
])

const [posLoc, posBuffer] = createAttrBuffer(gl, program, 'aPos', points)
const [colorLoc, colorBuffer] = createAttrBuffer(gl, program, 'aColor', colors)
const indexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer) // 绑定 buffer 到 ELEMENT_ARRAY_BUFFER
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW)

gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer) // 绑定 buffer 到 ARRAY_BUFFER
gl.vertexAttribPointer(posLoc, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(posLoc)

gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer) // 绑定 buffer 到 ARRAY_BUFFER
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(colorLoc)

gl.enable(gl.DEPTH_TEST)
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

gl.drawElements(
  gl.TRIANGLES, // 要渲染的图元类型
  indices.length, // 要渲染的元素数量
  gl.UNSIGNED_BYTE, // 元素数组缓冲区中的值的类型
  0 // 元素数组缓冲区中的偏移量, 字节单位
)

function createShader(gl, type, source) {
  const shader = gl.createShader(type)
  gl.shaderSource(shader, source)
  gl.compileShader(shader)
  return shader;
}

function createProgramFromSource(gl, vertex, fragment) {
  const vertexShader = createShader(gl, gl.VERTEX_SHADER,vertex)
  const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragment)
  const program = gl.createProgram()
  gl.attachShader(program, vertexShader)
  gl.attachShader(program, fragmentShader)
  gl.linkProgram(program)
  gl.useProgram(program)
  return program
}

function createAttrBuffer(gl, program, attr, data) {
  const location = gl.getAttribLocation(program, attr)
  const buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
  gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
  return [location, buffer]
}

function createGl(width = 500, height = 500) {
  const canvas = document.createElement('canvas')
  const gl = canvas.getContext('webgl')
  const dpr = window.devicePixelRatio || 1

  canvas.style.width = `${width}px`
  canvas.style.height = `${height}px`
  canvas.width = dpr * width
  canvas.height = dpr * height
  gl.viewport(0, 0, canvas.width, canvas.height)

  document.body.append(canvas)
  return gl
}

效果如下所示。

上面例子代码中封装了几个常用函数,来帮助我们加快 WebGL 应用开发,后续的例子中将直接使用这些工具函数,不会再完整的写出来。

要实现复用顶点,需要使用顶点索引,这样 WebGL 就可以通过索引找到对应顶点,因为索引下标都是整数且最大为 7 (因为只有 8 个顶点),所以这里使用了 Uint8Array

然后创建 Buffer 来存放这些索引,和其他 attribute 数据不同,索引数据的绑定目标是 ELEMENT_ARRAY_BUFFER

上面代码中,我们没有使用 drawArrays,而是使用 drawElements。 它们的主要区别是 drawArrays 是根据 ARRAY_BUFFER 来渲染,drawElements 是根据 ELEMENT_ARRAY_BUFFER 来渲染(根据索引来渲染)。

glDrawArray 速度快,费内存(有重复顶点数据)。glDrawElement 稍微慢点,省内存(只有一份顶点数据)。

因为我们渲染的是三维物体,需要区分哪个顶点在前哪个顶点在后。上面代码中使用 gl.enable(gl.DEPTH_TEST) 启用了深度测试,并且使用 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) 同时重置颜色缓存和深度缓存。后续文章将会详细讲解这个问题。

上面代码调用了多次 bindBuffer 方法,可能有同学会觉得已经在 createAttrBuffer 中调用了一次后面就不用调用了吧。

之前提到过 OpenGL 是一个的状态机,我们在操作 Buffer 之前,需要绑定对应的 Buffer,特别是在代码比较多的情况下,你可能不清楚当前绑定的是哪个 Buffer,直接对 Buffer 进行操作,可能操作到了其他 Buffer。所以在操作 Buffer 之前进行绑定,可以减少 BUG。

varying 存储限定字

最后再来看看 varying 存储限定字,存储限定字其实一共有三个 attributeuniformvarying。上面已经介绍了前两个,它们都是从外部 JS 获取数据。

varying 有点特殊,它用于从顶点着色器向片元着色器传送数据。上面例子中我们将 aColor 赋值给 vColor,然后在片段着色器中就可以使用 vColor 了。 叫 varying 也是有原因的,我们可以先来看看上面代码最终渲染成什么样子。

顶点着色器是逐顶点的,片段着色器是逐像素的,显然像素会比顶点多。varying 变量从顶点着色器向片元着色器传递时会被 OpenGL 插值,也就是我们定义了三角形 3 个顶点的颜色,三角形内部的像素都是根据这 3 个顶点颜色插值出来的。比如一个线段一个端点是红色,另一个是绿色,那么这个线段中间就是 50% 的红色和 50% 的绿色。

总结

这篇文章讲解了着色器相关知识,我们可以使用 GLSL 编写顶点着色器和片段着色器来控制物体各个顶点和位置和每个像素的颜色。

由于顶点着色器和片段着色器是在 GPU 中执行,我们需要给他们传递数据。

attribute 只能在顶点着色器中使用,可以用它来传递逐顶点的数据,我们会创建一个 Buffer 把数据放入 Buffer 中发送到 GPU,为了提升性能,需要使用 Float32Array,这样在 GPU 中就无需再解码数据。

uniform 类似全局常量,在顶点着色器和片段着色器中都可以使用,一般会使用它传递一些矩阵,后面文章会有讲解。

varying 是用于从顶点着色器向片段着色器传送数据,数据在传递到片段着色器之前会被插值。

在文章的最后我们渲染了一个立方体,但是看起来就像一个正方形,这是因为我们在正前方看这个立方体,为了让它看起来像一个立方体,我们需要让它转动起来,如何转动立方体呢?后续文章将会详细讲解。

如果觉得文章还不错欢迎点赞关注支持,我会尽快更新系列教程的下一篇文章。

零基础玩转 WebGL 系列文章目录请查看:零基础玩转 WebGL - 目录