文章目录
  1. 1. WebGL 概念入门
  2. 2. WebGL 示例入门
  3. 3. 如何用 WebGL 渲染图像
  4. 4. 如何为图像增加滤镜
  5. 5. 如何叠加多个图像
  6. 6. 如何组合多个滤镜
  7. 7. 如何引入 3D 效果
  8. 8. 如何封装自定渲染器
  9. 9. 后记

技术社区里有种很有意思的现象,那就是不少人们口耳相传中的强大技术,往往因为上手难度高而显得曲高和寡。从这个角度看来,WebGL 和函数式编程有些类似,都属于优势已被论证了多年,却一直较为不温不火的技术。但是,一旦这些技术的易用性跨越了某个临界点,它们其实并没有那么遥不可及。这次我们就将以 WebGL 为例,尝试降低它的入门门槛,讲解它在前端图像处理领域的应用入门。

临近 2020 年的今天,社区里已经有了许多 WebGL 教程。为什么还要另起炉灶再写一篇呢?这来自于笔者供职的稿定科技前端团队,在 WebGL 基础库层面进行技术创新的努力。前一段时间,我们开源了自主研发的 WebGL 基础库 Beam。它以 10KB 不到的体积,将传统上入门时动辄几百行的 WebGL 渲染逻辑降低到了几十行的量级,并在性能上也毫不妥协。开源两周内,Beam 的 Star 数量就达到了 GitHub 全站 WebGL Library 搜索条目下的前 10%,在国内也还没有定位相当的竞品。这次我们将借助 Beam 来编写 WebGL 渲染逻辑,用精炼的代码和概念告诉大家,该如何硬核而不失优雅地手动操控 GPU 渲染管线,实现多样的前端图像处理能力。

本文将覆盖的内容如下所示。我们希望能带着感兴趣的同学从零基础入门,直通具备实用价值的图像滤镜能力开发:

  • WebGL 概念入门
  • WebGL 示例入门
  • 如何用 WebGL 渲染图像
  • 如何为图像增加滤镜
  • 如何叠加多个图像
  • 如何组合多个滤镜
  • 如何引入 3D 效果
  • 如何封装自定渲染器

为了照顾没有基础的同学,在进入实际的图像处理部分前,我们会重新用 Beam 入门一遍 WebGL。熟悉相关概念的同学可以直接跳过这些部分。

WebGL 概念入门

Beam 的一个设计目标,是让使用者即便没有相关经验,也能靠它快速搞懂 WebGL。但这并不意味着它像 Three.js 那样可以几乎完全不用懂图形学,拿来就是一把梭。相比之下,Beam 选择对 WebGL 概念做高度的抽象。在学习理解这些概念后,你就不仅能理解 GPU 渲染管线,还能用简单的代码来操作它了。毕竟这篇文章本身,也是本着授人以渔的理念来写作的。

本节来自 如何设计一个 WebGL 基础库 一文,熟悉的同学可跳过。

WebGL 体系有很多琐碎之处,一头扎进代码里,容易使我们只见树木不见森林。然而我们真正需要关心的概念,其实可以被高度浓缩为这几个:

  • Shader 着色器,是存放图形算法的对象。 相比于在 CPU 上单线程执行的 JS 代码,着色器在 GPU 上并行执行,计算出每帧数百万个像素各自的颜色。
  • Resource 资源,是存放图形数据的对象。就像 JSON 成为 Web App 的数据那样,资源是传递给着色器的数据,包括大段的顶点数组、纹理图像,以及全局的配置项等。
  • Draw 绘制,是选好资源后运行着色器的请求。要想渲染真实际的场景,一般需要多组着色器与多个资源,来回绘制多次才能完成一帧。每次绘制前,我们都需要选好着色器,并为其关联好不同的资源,也都会启动一次图形渲染管线。
  • Command 命令,是执行绘制前的配置。WebGL 是非常有状态的。每次绘制前,我们都必须小心地处理好状态机。这些状态变更就是通过命令来实现的。Beam 基于一些约定大幅简化了人工的命令管理,当然你也可以定制自己的命令。

这些概念是如何协同工作的呢?请看下图:

图中的 Buffers / Textures / Uniforms 都属于典型的资源(后面会详述它们各自的用途)。一帧当中可能存在多次绘制,每次绘制都需要着色器和相应的资源。在绘制之间,我们通过命令来管理好 WebGL 的状态。仅此而已。

理解这个思维模型很重要。因为 Beam 的 API 设计就是完全依据这个模型而实现的。让我们进一步看看一个实际的场景吧:

图中我们绘制了很多质感不同的球体。这一帧的渲染,则可以这样解构到上面的这些概念下:

  • 着色器无疑就是球体质感的渲染算法。对经典的 3D 游戏来说,要渲染不同质感的物体,经常需要切换不同的着色器。但现在基于物理的渲染算法流行后,这些球体也不难做到使用同一个着色器来渲染。
  • 资源包括了大段的球体顶点数据、材质纹理的图像数据,以及光照参数、变换矩阵等配置项
  • 绘制是分多次进行的。我们选择每次绘制一个球体,而每次绘制也都会启动一次图形渲染管线。
  • 命令则是相邻的球体绘制之间,所执行的那些状态变更

如何理解状态变更呢?不妨将 WebGL 想象成一个具备大量开关与接口的仪器。每次按下启动键(执行绘制)前。你都要配置好一堆开关,再连接好一条接着色器的线,和一堆接资源的线,就像这样:

还有很重要的一点,那就是虽然我们已经知道,一帧画面可以通过多次绘制而生成,而每次绘制又对应执行一次图形渲染管线的执行。但是,所谓的图形渲染管线又是什么呢?这对应于这张图:

渲染管线,一般指的就是这样一个 GPU 上由顶点数据到像素的过程。对现代 GPU 来说,管线中的某些阶段是可编程的。WebGL 标准里,这对应于图中蓝色的顶点着色器和片元着色器阶段。你可以把它们想象成两个需要你写 C-style 代码,跑在 GPU 上的函数。它们大体上分别做这样的工作:

  • 顶点着色器输入原始的顶点坐标,输出经过你计算出的坐标。
  • 片元着色器输入一个像素位置,输出根据你计算出的像素颜色。

下面,我们将进一步讲解如何应用这些概念,搭建出一个完整的 WebGL 入门示例。

WebGL 示例入门

本节同样来自 如何设计一个 WebGL 基础库 一文,但为承接后续的图像处理内容,叙述有所调整。

在苦口婆心的概念介绍后,就要来到真刀真枪的编码阶段了。由于四大概念中的命令可以被自动化,我们只为 Beam 定义了三个核心 API,分别是:

  • beam.shader
  • beam.resource
  • beam.draw

显然地,它们各自管理着色器、资源和绘制。让我们看看怎样基于 Beam,来绘制 WebGL 中的 Hello World 彩色三角形吧:

三角形是最简单的多边形,而多边形则是由顶点组成的。WebGL 中的这些顶点是有序排列,可通过下标索引的。以三角形和矩形为例,这里使用的顶点顺序如下所示:

Beam 的代码示例如下,压缩后全部代码体积仅有 6KB

import { Beam, ResourceTypes } from 'beam-gl'
import { MyShader } from './my-shader.js'
const { VertexBuffers, IndexBuffer } = ResourceTypes

const canvas = document.querySelector('canvas')
const beam = new Beam(canvas)

const shader = beam.shader(MyShader)
const vertexBuffers = beam.resource(VertexBuffers, {
position: [
-1, -1, 0, // vertex 0 左下角
0, 1, 0, // vertex 1 顶部
1, -1, 0 // vertex 2 右下角
],
color: [
1, 0, 0, // vertex 0 红色
0, 1, 0, // vertex 1 绿色
0, 0, 1 // vertex 2 蓝色
]
})
const indexBuffer = beam.resource(IndexBuffer, {
array: [0, 1, 2] // 由 0 1 2 号顶点组成的三角形
})

beam
.clear()
.draw(shader, vertexBuffers, indexBuffer)

下面逐个介绍一些重要的 API 片段。首先自然是初始化 Beam 了:

const canvas = document.querySelector('canvas')
const beam = new Beam(canvas)

然后我们用 beam.shader 来实例化着色器,这里的 MyShader 稍后再说:

const shader = beam.shader(MyShader)

着色器准备好之后,就是准备资源了。为此我们需要使用 beam.resource API 来创建三角形的数据。这些数据装在不同的 Buffer 里,而 Beam 使用 VertexBuffers 类型来表达它们。三角形有 3 个顶点,每个顶点有两个属性 (attribute),即 positioncolor,每个属性都对应于一个独立的 Buffer。这样我们就不难用普通的 JS 数组(或 TypedArray)来声明这些顶点数据了。Beam 会替你将它们上传到 GPU:

注意区分 WebGL 中的顶点坐标概念。顶点 (vertex) 不仅可以包含一个点的坐标属性,还可以包含法向量、颜色等其它属性。这些属性都可以输入顶点着色器中来做计算。

const vertexBuffers = beam.resource(VertexBuffers, {
position: [
-1, -1, 0, // vertex 0 左下角
0, 1, 0, // vertex 1 顶部
1, -1, 0 // vertex 2 右下角
],
color: [
1, 0, 0, // vertex 0 红色
0, 1, 0, // vertex 1 绿色
0, 0, 1 // vertex 2 蓝色
]
})

装顶点的 Buffer 通常会使用很紧凑的数据集。我们可以定义这份数据的一个子集或者超集来用于实际渲染,以便于减少数据冗余并复用更多顶点。为此我们需要引入 WebGL 中的 IndexBuffer 概念,它指定了渲染时用到的顶点下标。这个例子里,0 1 2 这样的每个下标,都对应顶点数组里的 3 个位置:

const indexBuffer = beam.resource(IndexBuffer, {
array: [0, 1, 2] // 由 0 1 2 号顶点组成的三角形
})

最后我们就可以进入渲染环节啦。首先用 beam.clear 来清空当前帧,然后为 beam.draw 传入一个着色器对象和任意多个资源对象即可:

beam
.clear()
.draw(shader, vertexBuffers, indexBuffer)

我们的 beam.draw API 是非常灵活的。如果你有多个着色器和多个资源,可以随意组合它们来链式地完成绘制,渲染出复杂的场景。就像这样:

beam
.draw(shaderX, ...resourcesA)
.draw(shaderY, ...resourcesB)
.draw(shaderZ, ...resourcesC)

别忘了还有个遗漏的地方:如何决定三角形的渲染算法呢?这是在 MyShader 变量里指定的。它其实是个着色器的 Schema,像这样:

import { SchemaTypes } from 'beam-gl'

const vertexShader = `
attribute vec4 position;
attribute vec4 color;
varying highp vec4 vColor;
void main() {
vColor = color;
gl_Position = position;
}
`
const fragmentShader = `
varying highp vec4 vColor;
void main() {
gl_FragColor = vColor;
}
`

const { vec4 } = SchemaTypes
export const MyShader = {
vs: vertexShader,
fs: fragmentShader,
buffers: {
position: { type: vec4, n: 3 },
color: { type: vec4, n: 3 }
}
}

Beam 中的着色器 Schema,需要提供 fs / vs / buffers 等字段。这里的一些要点包括如下:

  • 可以粗略认为,顶点着色器对三角形每个顶点执行一次,而片元着色器则对三角形内的每个像素执行一次。
  • 顶点着色器和片元着色器,都是用 WebGL 标准中的 GLSL 语言编写的。这门语言其实就是 C 语言的变体,vec4 则是其内置的 4 维向量数据类型。
  • 在 WebGL 中,顶点着色器将 gl_Position 变量作为坐标位置输出,而片元着色器则将 gl_FragColor 变量作为像素颜色输出。本例中的顶点和片元着色器,执行的都只是最简单的赋值操作。
  • 名为 vColor 的 varying 变量,会由顶点着色器传递到片元着色器,并自动插值。最终三角形在顶点位置呈现我们定义的红绿蓝纯色,而其他位置则被渐变填充,这就是插值计算的结果。
  • 变量前的 highp 修饰符用于指定精度,也可以在着色器最前面加一行 precision highp float; 来省略为每个变量手动指定精度。在现在这个时代,基本可以一律用高精度了。
  • 这里 positioncolor 这两个 attribute 变量,和前面 vertexBuffers 中的 key 相对应。这也是 Beam 中的隐式约定。

虽然到此为止的信息量可能比较大,但现在只要区区几十行代码,我们就可以清晰地用 Beam 来手动控制 WebGL 渲染了。接下来让我们看看,该如何把渲染出的三角形换成矩形。有了上面的铺垫,这个改动就显得非常简单了,稍微改几行代码就行。

我们的目标如下图所示:

这对应于这样的代码:

const vertexBuffers = beam.resource(VertexBuffers, {
position: [
-1, -1, 0, // vertex 0 左下角
-1, 1, 0, // vertex 1 左上角
1, -1, 0, // vertex 2 右下角
1, 1, 0 // vertex 3 右上角
],
color: [
1, 0, 0, // vertex 0 红色
0, 1, 0, // vertex 1 绿色
0, 0, 1, // vertex 2 蓝色
1, 1, 0 // vertex 3 黄色
]
})

const indexBuffer = beam.resource(IndexBuffer, {
array: [
0, 1, 2, // 左下三角形
1, 2, 3 // 右上三角形
]
})

其他代码完全不用改动,我们就能看到 Canvas 被填满了。这正好告诉了我们另一个重要信息:WebGL 的屏幕坐标系以画布中央为原点,画布左下角为 (-1, -1),右上角则为 (1, 1)。如下图所示:

注意,不论画布长宽比例如何,这个坐标系的范围都是 -1 到 1 的。只要尝试更改一下 Canvas 的尺寸,你就能知道这是什么意思了。

到目前为止,我们的渲染算法,其实只有片元着色器里的这一行:

void main() {
gl_FragColor = vColor;
}

对每个像素,这个 main 函数都会执行,将插值后的 varying 变量 vColor 颜色直接赋给 gl_FragColor 作为输出。能不能玩出些花样呢?很简单:

gl_FragColor = vec4(0.8, 0.9, 0.6, 0.4); // 固定颜色
gl_FragColor = vColor.xyzw; // 四个分量的语法糖
gl_FragColor = vColor.rgba; // 四个分量的等效语法糖
gl_FragColor = vColor.stpq; // 四个分量的等效语法糖
gl_FragColor = vColor + vec4(0.5); // 变淡
gl_FragColor = vColor * 0.5; // 变暗
gl_FragColor = vColor.yxzw; // 交换 X 与 Y 分量
gl_FragColor = vColor.rbga; // 交换 G 与 B 分量
gl_FragColor = vColor.rrrr; // 灰度展示 R 分量
gl_FragColor = vec4(vec2(0), vColor.ba); // 清空 R 与 G 分量

这一步的例子,可以在 Hello World 这里访问到。

虽然这些例子只示范了 GLSL 的基本语法,但别忘了这可是编译到 GPU 上并行计算的代码,和单线程的 JS 有着云泥之别。只不过,目前我们的输入都是由各顶点之间的颜色插值而来,因此效果难以超出普通渐变的范畴。该怎样渲染出常见的点阵图像呢?到此我们终于可以进入正题,介绍与图像处理关系最为重大的纹理资源了。

如何用 WebGL 渲染图像

为了进行图像处理,浏览器中的 Image 对象显然是必须的输入。在 WebGL 中,Image 对象可以作为纹理,贴到多边形表面。这意味着,在片元着色器里,我们可以根据某种规则来采样图像的某个位置,将该位置的图像颜色作为输入,计算出最终屏幕上的像素颜色。显然,这个过程需要在着色器里表达图像的不同位置,这用到的就是所谓的纹理坐标系了。

纹理坐标系又叫 ST 坐标系。它以图像左下角为原点,右上角为 (1, 1) 坐标,同样与图像的宽高比例无关。这一坐标系的具体形式如下所示,配图来自笔者在卢浮宫拍摄的维纳斯像(嘿嘿)

还记得我们先前给每个顶点附带了什么 attribute 属性吗?坐标颜色。现在,我们需要将颜色换成纹理坐标,从而告诉 WebGL,正方形的每一个顶点应该对齐图像的哪一个位置,就像把被单的四个角对齐被套一样。这也就意味着我们需要依序提供上图中,纹理图像四个角落的坐标。若将这四个坐标当作颜色绘制出来,就能得到下图:

不难看出,图中左下角对应 RGB 下的 (0, 0, 0) 黑色;左上角对应 RGB 下的 (0, 1, 0) 绿色;右下角对应 RGB 下的 (1, 0, 0) 红色;右上角则对应 RGB 下的 (1, 1, 0) 黄色。由此可见,这几个颜色 R 通道和 G 通道分量的取值,就和纹理坐标系中对应的 X Y 位置一致。这样一来,我们就用 RGB 颜色验证了数据的正确性。这种技巧也常对着色算法调试有所帮助。

和屏幕坐标系超出 (-1, 1) 区间就会被裁掉不同,纹理坐标系的取值可以是任意的正负浮点数。那么超过区间该怎么办呢?默认行为是平铺,像这样:

但平铺不是唯一的行为。我们也可以修改 WebGL 状态,让纹理呈现出不同的展示效果(即所谓的 Wrap 缠绕模式),如下所示:

除此之外,纹理还有采样方式等其他配置可供修改。我们暂且不考虑这么多,看看应该怎么将最基本的图像作为纹理渲染出来吧:

// 创建着色器
const shader = beam.shader(TextureDemo)

// 创建用于贴图的矩形
const rect = {
vertex: {
position: [
-1.0, -1.0, 0.0,
1.0, -1.0, 0.0,
1.0, 1.0, 0.0,
-1.0, 1.0, 0.0
],
texCoord: [
0.0, 0.0,
1.0, 0.0,
1.0, 1.0,
0.0, 1.0
]
},
index: { array: [0, 1, 2, 0, 2, 3] }
}
const vertexBuffers = beam.resource(VertexBuffers, rect.vertex)
const indexBuffer = beam.resource(IndexBuffer, rect.index)

// 创建纹理资源
const textures = beam.resource(Textures)

// 异步加载图像
fetchImage('venus.jpg').then(image => {
// 设入纹理图像后,执行绘制
textures.set('img', { image, flip: true })
beam
.clear()
.draw(shader, vertexBuffers, indexBuffer, textures)
})

类似地,我们还是先看整体的渲染逻辑,再看着色器。整个过程其实很简单,可以概括为三步:

  1. 初始化着色器、矩形资源和纹理资源
  2. 异步加载图像,完成后把图像设置为纹理
  3. 执行绘制

相信大家在熟悉 Beam 的 API 后,应该不会觉得这部分代码有什么特别之处了吧。下面我们来关注重要的 TextureDemo 着色器部分,如下所示:

const vs = `
attribute vec4 position;
attribute vec2 texCoord;
varying highp vec2 vTexCoord;

void main() {
vTexCoord = texCoord;
gl_Position = position;
}
`

const fs = `
varying highp vec2 vTexCoord;
uniform sampler2D img;

void main() {
gl_FragColor = texture2D(img, vTexCoord);
}
`

const { vec2, vec4, tex2D } = SchemaTypes
export const TextureDemo = {
vs,
fs,
buffers: {
position: { type: vec4, n: 3 },
texCoord: { type: vec2 }
},
textures: {
img: { type: tex2D }
}
}

就像 vColor 那样地,我们将 vTexCoord 变量从顶点着色器传入了片元着色器,这时同样隐含了插值处理。

这组着色器中,真正值得一提的是这么两行:

uniform sampler2D img;
// ...
gl_FragColor = texture2D(img, vTexCoord);

你可以认为,片元着色器中 uniform sampler2D 类型的 img 变量,会被绑定到一张图像纹理上。然后,我们就可以用 WebGL 内置的 texture2D 函数来做纹理采样了。因此,这个着色器的渲染算法,其实就是采样 img 图像的 vTexCoord 位置,将获得的颜色作为该像素的输出。对整个矩形内的每个像素点都执行一遍这个采样过程后,自然就把图像搬上屏幕了

让我们先歇一口气,欣赏下渲染出来的高雅艺术吧:

这一步的例子,可以在 Texture Config 这里访问到。

如何为图像增加滤镜

现在,图像的采样过程已经处于我们的着色器代码控制之下了。这意味着我们可以轻易地控制每个像素的渲染算法,实现图像滤镜。这具体要怎么做呢?下面拿这张笔者在布拉格拍的伏尔塔瓦河做例子(嘿嘿嘿)

我们看到了一张默认彩色的图像。最常见的滤镜操作之一,就是将它转为灰度图。这有很多种实现方式,而其中最简单的一种,不外乎把 RGB 通道的值全设置成一样的:

// 先采样出纹理的 vec4 颜色
vec4 texColor = texture2D(img, vTexCoord);

// 然后可以这样
gl_FragColor = texColor.rrra;

// 或者这样
float average = (texColor.r + texColor.g + texColor.b) / 3.0;
gl_FragColor = vec4(vec3(average), texColor.a);

注意,在严格意义上,灰度化既不是用 R 通道覆盖 RGB,也不是对 RGB 通道简单取平均,而需要一个比例系数。这里为入门做了简化,效果如图:

目前为止我们的着色器里,真正有效的代码都只有一两行而已。让我们来尝试下面这个更复杂一些的饱和度滤镜吧:

precision highp float;
uniform sampler2D img;
varying vec2 vTexCoord;

const float saturation = 0.5; // 饱和度比例常量

void main() {
vec4 color = texture2D(img, vTexCoord);
float average = (color.r + color.g + color.b) / 3.0;
if (saturation > 0.0) {
color.rgb += (average - color.rgb) * (1.0 - 1.0 / (1.001 - saturation));
} else {
color.rgb += (average - color.rgb) * (-saturation);
}
gl_FragColor = color;
}

这个算法本身不是我们关注的重点,你很容易在社区找到各种各样的着色器。这里主要只是想告诉大家,着色器里是可以写 if else 的……

增加饱和度后,效果如图所示:

但这里还有一个不大不小的问题,那就是现在的饱和度比例还是这样的一个常量:

const float saturation = 0.5;

如果要实现「拖动滑块调节滤镜效果强度」这样常见的需求,难道要不停地更改着色器源码吗?显然不是这样的。为此,我们需要引入最后一种关键的资源类型:Uniform 资源。

在 WebGL 中,Uniform 概念类似于全局变量。一般的全局变量,是在当前代码中可见,而 Uniform 则对于这个着色器并行中的每次执行,都是全局可见并唯一的。这样,着色器在计算每个像素的颜色时,都能拿到同一份「强度」参数的信息了。像上面 uniform sampler2D 类型的纹理采样器,就是这样的一个 Uniform 变量。只不过 Beam 处理了琐碎的下层细节,你只管把 JS 中的 Image 对象按约定传进来,就能把图像绑定到这个着色器变量里来使用了。

每个 Uniform 都是一份短小的数据,如 vec4 向量或 mat4 矩阵等。要想使用它,可以从简单的着色器代码修改开始:

precision highp float;
varying vec2 vTexCoord;

uniform sampler2D img;
uniform float saturation; // 由 const 改为 uniform

该怎么给这个变量赋值呢?在 Schema 里适配一下就行:

const { vec2, vec4, float, tex2D } = SchemaTypes
export const TextureDemo = {
vs,
fs,
buffers: {
position: { type: vec4, n: 3 },
texCoord: { type: vec2 }
},
textures: {
img: { type: tex2D }
},
// 新增这个 uniforms 字段
uniforms: {
saturation: { type: float, default: 0.5 }
}
}

这里的 default 属于方便调试的语法糖,理论上这时代码的运行结果是完全一致的,只是 saturation 变量从 Shader 中的常量变成了从 JS 里传入。怎么进一步控制它呢?其实也很简单,只需要 beam.draw 的时候多传入个资源就行了:

// ...
// 创建 Uniform 资源
const uniforms = beam.resource(Uniforms, {
saturation: 0.5
})

// 异步加载图像
fetchImage('venus.jpg').then(image => {
textures.set('img', { image, flip: true })

// Uniform 可以随时更新
// uniforms.set('saturation', 0.4)
beam
.clear()
.draw(shader, vertexBuffers, indexBuffer, uniforms, textures)
})

这样,我们就可以在 JS 中轻松地控制滤镜的强度了。像典型 3D 场景中,也是这样通过 Uniform 来控制相机位置等参数的。

我们还可以将 Uniform 数组与卷积核函数配合,实现图像的边缘检测、模糊等效果,并支持无缝的效果强度调整。不要怕所谓的卷积和核函数,它们的意思只是「计算一个像素时,可以采样它附近的像素」而已。由于这种手法并不需要太多额外的 WebGL 能力,这里就不再展开了。

这一步的例子,可以在 Single Filter 这里访问到。

如何叠加多个图像

现在,我们已经知道如何为单个图像编写着色器了。但另一个常见的需求是,如何处理需要混叠的多张图像呢?下面让我们看看该如何处理这样的图像叠加效果:

JS 侧的渲染逻辑如下所示:

// ...
const render = ([imageA, imageB]) => {
const imageStates = {
img0: { image: imageA, flip: true },
img1: { image: imageB, flip: true }
}

beam.clear().draw(
shader,
beam.resource(VertexBuffers, rect.vertex),
beam.resource(IndexBuffer, rect.index),
beam.resource(Textures, imageStates)
)
}

loadImages('html5-logo.jpg', 'black-hole.jpg').then(render)

这里只需要渲染一次,故而没有单独为 VertexBuffersIndexBuffer 等资源变量命名,直接在 draw 的时候初始化就行。那么关键的 Shader 该如何实现呢?此时的着色器 Schema 结构是这样的:

const fs = `
precision highp float;
uniform sampler2D img0;
uniform sampler2D img1;
varying vec2 vTexCoord;

void main() {
vec4 color0 = texture2D(img0, vTexCoord);
vec4 color1 = texture2D(img1, vTexCoord);
gl_FragColor = color0 * color1.r;
}
`

const { vec2, vec4, mat4, tex2D } = SchemaTypes
export const MixImage = {
vs, // 顶点着色器和前例相同
fs,
buffers: {
position: { type: vec4, n: 3 },
texCoord: { type: vec2 }
},
textures: {
img0: { type: tex2D },
img1: { type: tex2D }
}
}

这里的核心代码在于 gl_FragColor = color0 * color1.r; 这一句,而这两个颜色则分别来自于对两张图像的 texture2D 采样。有了更丰富的输入,我们自然可以有更多的变化可以玩了。比如这样:

gl_FragColor = color0 * (1.0 - color1.r);

就可以得到相反的叠加结果。

在现在的 WebGL 里,我们一般可以至少同时使用 16 个纹理。这个上限说实话也不小了,对于常见的图像混叠需求也都能很好地满足。但是浏览器自身也是通过类似的 GPU 渲染管线来渲染的,它是怎么渲染页面里动辄成百上千张图像的呢?这说起来知易行难,靠的是分块多次绘制。

这一步的例子,可以在 Mix Images 这里访问到。

如何组合多个滤镜

到现在为止我们已经单独实现过多种滤镜了,但如何将它们的效果串联起来呢?WebGL 的着色器毕竟是字符串,我们可以做魔改拼接,生成不同的着色器。这确实是许多 3D 库中的普遍实践,也利于追求极致的性能。但这里选择的是一种工程上实现更为简洁优雅的方式,即离屏的链式渲染。

假设我们有 A B C 等多种滤镜(即用于图像处理的着色器),那么该如何将它们的效果依次应用到图像上呢?我们需要先为原图应用滤镜 A,然后将 A 的渲染结果传给 B,再将 A + B 的渲染结果传给 C…依此类推,即可组成一条完整的滤镜链。

为了实现这一目标,我们显然需要暂存某次渲染的结果。熟悉 Canvas 的同学一定对离屏渲染不陌生,在 WebGL 中也有类似的概念。但 WebGL 的离屏渲染,并不像 Canvas 那样能直接新建多个离屏的 <canvas> 标签,而是以渲染到纹理的方式来实现的。

在给出代码前,我们需要先做些必要的科普。在 WebGL 和 OpenGL 体系中有个最为经典的命名槽点,那就是 Buffer 和 Framebuffer 其实完全是两种东西(不要误给 Framebuffer 加了驼峰命名噢)。Buffer 可以理解为存储大段有序数据的对象,而 Framebuffer 指代的则是屏幕!一般来说,我们渲染到屏幕时,使用的就是默认的物理 Framebuffer。但离屏渲染时,我们渲染的 Framebuffer 是个虚拟的对象,即所谓的 Framebuffer Object (FBO)。纹理对象可以 attach 到 Framebuffer Object 上,这样绘制时就会将像素数据写到内存,而不是物理显示设备了。

上面的介绍有些绕口,其实只要记住这两件事就对了:

  • 离屏渲染时,要将渲染目标从物理 Framebuffer 换成 FBO。
  • FBO 只是个壳,要将纹理对象挂载上去,这才是像素真正写入的地方。

对离屏渲染,Beam 也提供了完善的支持。FBO 有些绕口,因此 Beam 提供了名为 OffscreenTarget 的特殊资源对象。这种对象该如何使用呢?假设现在我们有 3 个着色器,分别是用于调整对比度、色相和晕影的滤镜,那么将它们串联使用的代码示例如下:

import { Beam, ResourceTypes, Offscreen2DCommand } from 'beam-gl'

// ...
const beam = new Beam(canvas)
// 默认导入的最小包不带离屏支持,需手动扩展
beam.define(Offscreen2DCommand)

// ...
// 原图的纹理资源
const inputTextures = beam.resource(Textures)

// 中间环节所用的纹理资源
const outputTextures = [
beam.resource(Textures),
beam.resource(Textures)
]

// 中间环节所用的离屏对象
const targets = [
beam.resource(OffscreenTarget),
beam.resource(OffscreenTarget)
]

// 将纹理挂载到离屏对象上,这步的语义暂时还不太直观
outputTextures[0].set('img', targets[0])
outputTextures[1].set('img', targets[1])

// 固定的矩形 Buffer 资源
const rect= [rectVertex, rectIndex]

const render = image => {
// 更新输入纹理
inputTextures.set('img', { image, flip: true })

beam.clear()
beam
// 用输入纹理,渲染对比度滤镜到第一个离屏对象
.offscreen2D(targets[0], () => {
beam.draw(contrastShader, ...rect, inputTextures)
})
// 用第一个输出纹理,渲染色相滤镜到第二个离屏对象
.offscreen2D(targets[1], () => {
beam.draw(hueShader, ...rect, outputTextures[0])
})

// 用第二个输出纹理,渲染晕影滤镜直接上屏
beam.draw(vignetteShader, ...rect, outputTextures[1])
}

fetchImage('prague.jpg').then(render)

这里的渲染逻辑,其实只是将原本这样的代码结构:

beam
.clear()
.draw(shaderX, ...resourcesA)
.draw(shaderY, ...resourcesB)
.draw(shaderZ, ...resourcesC)

换成了扩展 offscreen2D API 后的这样:

beam
.clear()
.offscreen2D(targetP, () => {
beam.draw(shaderX, ...resourcesA)
})
.offscreen2D(targetQ, () => {
beam.draw(shaderY, ...resourcesB)
})
.offscreen2D(targetR, () => {
beam.draw(shaderZ, ...resourcesC)
})
// 还需要在外面再 beam.draw 一次,才能把结果上屏

只要被嵌套在 offscreen2D 函数里,那么 beam.draw 在功能完全不变的前提下,渲染结果会全部走到相应的离屏对象里,从而写入离屏对象所挂载的纹理上。这样,我们就用函数的作用域表达出了离屏渲染的作用域!这是 Beam 的一大创新点,能用来支持非常灵活的渲染逻辑。比如这样的嵌套渲染结构,也是完全合法的:

beam
.clear()
.offscreen2D(target, () => {
beam
.draw(shaderX, ...resourcesA)
.draw(shaderY, ...resourcesB)
.draw(shaderZ, ...resourcesC)
})
.draw(shaderW, ...resourcesD)

离屏渲染的 API 看似简单,其实是 Beam 中耗费最多时间设计的特性之一,目前的方案也是经历过若干次失败的尝试,推翻了用数组、树和有向无环图来结构化表达渲染逻辑的方向后才确定的。当然它目前也还有不够理想的地方,希望大家可以多反馈意见和建议。

现在,我们就能尝到滤镜链在可组合性上的甜头了。在依次应用了对比度、色相和晕影三个着色器后,渲染效果如下所示:

这一步的例子,可以在 Multi Filters 这里访问到。

如何引入 3D 效果

现在,我们已经基本覆盖了 2D 领域的 WebGL 图像处理技巧了。那么,是否有可能利用 WebGL 在 3D 领域的能力,实现一些更为强大的特效呢?当然可以。下面我们就给出一个基于 Beam 实现「高性能图片爆破轮播」的例子。

本节内容源自笔者在 现在作为前端入门,还有必要去学习高难度的 CSS 和 JS 特效吗?问题下的问答。阅读过这个回答的同学也可以跳过。

相信大家应该见过一些图片爆炸散开成为粒子的效果,这实际上就是将图片拆解为了一堆形状。这时不妨假设图像位于单位坐标系上,将图像拆分为许多爆破粒子,每个粒子都是由两个三角形组成的小矩形。摄像机从 Z 轴俯视下去,就像这样:

相应的数据结构呢?以上图的粒子为例,其中一个刚好在 X 轴中间的顶点,大致需要这些参数:

  • 空间位置,是粒子的三维坐标,这很好理解
  • 纹理位置,告诉 GPU 需要采样图像的哪个部分
  • 粒子中心位置,相当于让四个顶点团结在一起的 ID,免得各自跑偏了

只要 50 行左右的 JS,我们就可以完成初始数据的计算:

// 这种数据处理场景下,这个简陋的 push 性能好很多
const push = (arr, x) => { arr[arr.length] = x }

// 生成将图像等分为 n x n 矩形的数据
const initParticlesData = n => {
const [positions, centers, texCoords, indices] = [[], [], [], []]

// 这种时候求别用 forEach 了
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const [x0, x1] = [i / n, (i + 1) / n] // 每个粒子的 x 轴左右坐标
const [y0, y1] = [j / n, (j + 1) / n] // 每个粒子的 y 轴上下坐标
const [xC, yC] = [x0 + x1 / 2, y0 + y1 / 2] // 每个粒子的中心二维坐标
const h = 0.5 // 将中心点从 (0.5, 0.5) 平移到原点的偏移量

// positions in (x, y), z = 0
push(positions, x0 - h); push(positions, y0 - h)
push(positions, x1 - h); push(positions, y0 - h)
push(positions, x1 - h); push(positions, y1 - h)
push(positions, x0 - h); push(positions, y1 - h)

// texCoords in (x, y)
push(texCoords, x0); push(texCoords, y0)
push(texCoords, x1); push(texCoords, y0)
push(texCoords, x1); push(texCoords, y1)
push(texCoords, x0); push(texCoords, y1)

// center in (x, y), z = 0
push(centers, xC - h); push(centers, yC - h)
push(centers, xC - h); push(centers, yC - h)
push(centers, xC - h); push(centers, yC - h)
push(centers, xC - h); push(centers, yC - h)

// indices
const k = (i * n + j) * 4
push(indices, k); push(indices, k + 1); push(indices, k + 2)
push(indices, k); push(indices, k + 2); push(indices, k + 3)
}
}

// 着色器内的变量名是单数形式,将复数形式的数组名与其对应起来
return {
pos: positions,
center: centers,
texCoord: texCoords,
index: indices
}
}

现在我们已经能把原图拆分为一堆小矩形来渲染了。但这样还不够,因为默认情况下这些小矩形都是连接在一起的。借鉴一般游戏中粒子系统的实现,我们可以把动画算法写到着色器里,只逐帧更新一个随时间递增的数字,让 GPU 推算出每个粒子不同时间应该在哪。配套的着色器实现如下:

/* 这是顶点着色器,片元着色器无须改动 */

attribute vec4 pos;
attribute vec4 center;
attribute vec2 texCoord;

uniform mat4 viewMat;
uniform mat4 projectionMat;
uniform mat4 rotateMat;
uniform float iTime;

varying vec2 vTexCoord;
const vec3 camera = vec3(0, 0, 1);

// 伪随机数生成器
float rand(vec2 co) {
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
}

void main() {
// 求出粒子相对于相机位置的单位方向向量,并附带上伪随机数的扰动
vec3 dir = normalize(center.xyz * rand(center.xy) - camera);
// 沿扰动后的方向,随时间递增偏移量
vec3 translatedPos = pos.xyz + dir * iTime;

// 给纹理坐标插值
vTexCoord = texCoord;
// 求出矩阵变换后最终的顶点位置
gl_Position = projectionMat * viewMat * vec4(translatedPos, 1);
}

由于进入了 3D 世界,因此这个着色器引入了经典的 MVP 矩阵变换。这其实也已经远离了本文的主题,相信感兴趣的同学一定不难找到入门资料学习掌握。这个粒子效果的 Demo 如下所示。这里我们特意降低了粒子数量,方便大家看清它是怎么一回事:

如果基于 CSS,只要有了几百个 DOM 元素要高频更新,渲染时就会显得力不从心。而相比之下基于 WebGL,稳定 60 帧更新几万个粒子是完全不成问题的。由此可见,在与图像处理相关的特效层面,WebGL 始终是有它的用武之地的。

这一步的例子,可以在 Image Explode 这里访问到。

如何封装自定渲染器

最后,我们将视野回到前端工程,简单聊聊如何封装自己的渲染器。

Beam 自身不是一个 WebGL 渲染器或渲染引擎,而是方便大家编写渲染逻辑的通用基础库。当我们想要进一步复用渲染逻辑的时候,封装出自己的 WebGL 渲染器就显得必要了。

这里用 JS 中最为标准化的 class,演示如何封装出一个简单的滤镜渲染器:

class FilterRenderer {
constructor (canvas) {
this.beam = new Beam(canvas)
this.shader = this.beam(MyShader)
this.rect = createRect()
this.textures = this.beam.resource(Textures)
this.uniforms = this.beam.resource(Uniforms, {
strength: 0
})
}

setStrength (strength) {
this.uniforms.set('strength', strength)
}

setImage (image) {
this.textures.set('img', { image, flip: true })
}

render () {
const { beam, shader, rect, textures, uniforms } = this
beam
.clear()
.draw(shader, rect, textures, uniforms)
}
}

只要这样,在使用时就可以完全把 Beam 的 WebGL 概念屏蔽掉了:

const renderer = new FilterRenderer(canvas)
renderer.setImage(myImage)
renderer.setStrength(1)
renderer.render()

这时值得注意的地方有这么几个:

  • 尽量在构造器对应的初始化阶段分配好资源
  • 尽量不要高频更新大段的 Buffer 数据
  • 不用的纹理和 Buffer 等资源要手动用 destroy 方法销毁掉

当然,JS 中的 class 也不完美,而新兴的 Hooks 等范式也有潜力应用到这一领域,实现更好的 WebGL 工程化设计。该如何根据实际需求,定制出自己的渲染器呢?这就要看大家的口味和追求啦。

后记

为了尽量将各种重要的 WebGL 技巧浓缩在一起,快速达到足够实用的程度,本文篇幅显得有些长。虽然 Beam 的入门相对于 Vue 和 React 这样的常见框架来说还是有些门槛,但相比于一般需要分许多篇连载才能覆盖图像处理和离屏渲染的 WebGL 教程来说,我们已经屏蔽掉许多初学时不必关心的琐碎细节了。也欢迎大家对这种行文方式的反馈。

值得一提的是,Beam 不是一个为图像处理而生的库,API 中也没有为这个场景做任何特殊定制。它的设计初衷,其实是作为我司 3D 文字功能的渲染器。但由于它的 WebGL 基础库定位,它在 10KB 不到的体积下,不仅能平滑地从 3D 应用到 2D,甚至在 2D 场景下的扩展性,还能轻松超过 glfx.js 这样尚不支持滤镜链的社区标杆。这也反映出了设计框架时常有的两种思路:一种是为每个新需求来者不拒地设计新的 API,将框架实现得包罗万象;另一种是谨慎地找到需求间的共性,实现最小的 API 子集供使用者组合定制。显然笔者更倾向于后者。

Beam 的后续发展,也需要大家的支持——其实只要你不吝于给它个 Star 就够了。这会给我们更大的动力继续争取资源来维护它,或者进一步分享更多的 WebGL 知识与经验。欢迎大家移步这里:

Beam - Expressive WebGL

到此为止,相信我们已经对 WebGL 在图像处理领域的基本应用有了代码层面的认识了。希望大家对日常遇到的技术能少些「这么底层我管不来,用别人封装的东西就好」的心态,保持对舒适区外技术的学习热情,为自主创新贡献自己哪怕是微小的一份力量。

文章目录
  1. 1. WebGL 概念入门
  2. 2. WebGL 示例入门
  3. 3. 如何用 WebGL 渲染图像
  4. 4. 如何为图像增加滤镜
  5. 5. 如何叠加多个图像
  6. 6. 如何组合多个滤镜
  7. 7. 如何引入 3D 效果
  8. 8. 如何封装自定渲染器
  9. 9. 后记