图形系统如何表示颜色

# 图形系统如何表示颜色

在可视化领域中,图形的形状和颜色信息非常重要,它们都可以用来表达数据。

Web 图形系统中表示颜色的基本方法有 4 种,分别是 RGB 和 RGBA 颜色表示法HSL 和 HSV 颜色表示法CIE Lab 和 CIE Lch 颜色表示法以及 Cubehelix 色盘

# RGB 和 RGBA 颜色表示法

在 CSS 样式中形如 #RRGGBB 的颜色代码,就是 RGB (opens new window) 颜色的十六进制表示法,其中 RR、GG、BB 分别是两位十六进制数字,表示红、绿、蓝三色通道的色阶。色阶可以表示某个通道的强弱。

因为 RGB(A) 颜色用两位十六进制数来表示每一个通道的色阶,所以每个通道一共有 256 阶,取值是 0 到 255。RGB 的三个通道色阶的组合,理论上一共能表示 也就是一共 16777216 种不同的颜色。因此,RGB 颜色是将人眼可见的颜色表示为红、绿、蓝三原色不同色阶的混合。

事实上,RGB 色值只能表示人眼所能见到的所有颜色中的一个区域,不能表示全部颜色。不过,尽管 RGB 色值不能表示人眼可见的全部颜色,但它可以 表示的颜色也已经足够丰富了。一般的显示器、彩色打印机、扫描仪等都支持它。

在浏览器中,CSS 一般有两种表示 RGB 颜色值的方式:一种是我们前面说的 #RRGGBB 表示方式,另一种是直接用 rgb(red, green, blue) 表示颜色,这里的 “red、green、blue” 是十进制数值。

RGBA 就是在 RGB 的基础上增加了一个 Alpha 通道,也就是透明度。一些新版本的浏览器,可以用 #RRGGBBAA 的形式来表示 RGBA 色值,但是较早期的浏览器,只支持 rgba(red, green, blue, alpha) 这种形式来表示色值(注意:这里的 alpha 是一个从 0 到 1 的数)。

WebGL 的 shader 默认支持 RGBA。因为在 WebGL 的 shader 中,我们是使用一个四维向量来表示颜色的,向量的 r、g、b、a 分量分别表示红色、绿色、蓝色和 alpha 通道。不过和 CSS 的颜色表示稍有不同的是,WebGL 采用归一化的浮点数值,也就是说,WebGL 的颜色分量 r、g、b、a 的数值都是 0 到 1 之间的浮点数。

# RGB 和 RGBA 的局限性

当要选择一组颜色给图表使用时,我们并不知道要以什么样的规则来配置颜色,才能让不同数据对应的图形之间的对比尽可能鲜明。因此,RGB 颜色对用户其实并不友好。比如可以看这个例子 (opens new window)

因此,在需要动态构建视觉颜色效果的时候,我们很少直接选用 RGB 色值,而是使用其他的颜色表示形式。这其中,比较常用的就是 HSL 和 HSV 颜色表示形式。

# HSL 和 HSV 颜色表示法

HSL 和 HSV (opens new window)色相(Hue)、饱和度(Saturation)和亮度(Lightness)或明度(Value)来表示颜色。其中,Hue 是角度,取值范围是 0 到 360 度,饱和度和亮度/明度的值都是从 0 到 100%。

HSL 和 HSV 颜色可以理解为,是将 RGB 颜色的立方体从直角坐标系投影到极坐标的圆柱上,所以它的色值和 RGB 色值是一一对应的。

HSV 跟 RGB 之间色值的转换算法比较复杂。不过好在,CSS 和 Canvas2D 都可以直接支持 HSL 颜色,只有 WebGL 需要做转换。

下面是 RGB 和 HSV 的转换代码,可以记住。

// rgb 转 hsv
vec3 rgb2hsv(vec3 c) {
  vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
  vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
  vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
  float d = q.x - min(q.w, q.y);
  float e = 1.0e-10;
  return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

// hsv 转 rgb
vec3 hsv2rgb(vec3 c) {
  vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) -1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# HSL 和 HSV 的局限性

HSL 和 HSV 的数值变换与人眼感知并不完全相符。比如可以看例子 1 (opens new window)例子 2 (opens new window)

# 符合人类知觉的颜色标准

这个标准在描述颜色的时候要尽可能地满足以下 2 个原则:

  • 人眼看到的色差 = 颜色向量间的欧氏距离

  • 相同的亮度,能让人感觉亮度相同

基于此,一个针对人类感觉的颜色描述方式就产生了,它就是 CIE Lab。

# CIE Lab 和 CIE Lch 颜色表示法

CIE Lab 颜色空间 (opens new window)简称 Lab,它其实就是一种符合人类感觉的色彩空间,它用 L 表示亮度,a 和 b 表示颜色对立度。RGB 值可以进行 Lab 转换,但是转换规则比较复杂。

CIE Lab 比较特殊的一点是,目前还没有能支持 CIE Lab 的图形系统,但是 css-color level4 (opens new window) 规范已经给出了 Lab 颜色值的定义。

lab() = lab( <percentage> <number> <number> [ / <alpha-value> ]? )
1

而且,一些 JavaScript 库也已经可以直接处理 Lab 颜色空间了,如 d3-color (opens new window)

CIE Lch 和 CIE Lab 的对应方式类似于 RGB 和 HSL 和 HSV 的对应方式,也是将坐标从立方体的直角坐标系变换为圆柱体的极坐标系。

CIE Lab 的实现效果可以看这个例子 (opens new window)

# Cubehelix 色盘

Cubehelix 色盘(立方螺旋色盘),简单来说,它的原理就是在 RGB 的立方中构建一段螺旋线,让色相随着亮度增加螺旋变换

相关的库有:cubehelix (opens new window)

import { cubehelix } from "cubehelix";
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
ctx.translate(0, 256);
ctx.scale(1, -1);

// 使用 cubehelix 函数创建一个 color 映射
// cubehelix 函数是一个高阶函数,它的返回值是一个色盘映射函数
// 这个返回函数的参数范围是 0 到 1,当它从小到大依次改变的时候,不仅颜色会依次改变,亮度也会依次增强
const color = cubehelix();

const T = 2000;
function update(t) {
  const p = 0.5 + 0.5 * Math.sin(t / T); // 用正弦函数来模拟数据的周期性变化
  ctx.clearRect(0, -256, 512, 512);
  const { r, g, b } = color(p); // 获取当前的颜色值
  ctx.fillStyle = `rgb(${255 * r}, ${255 * g}, ${255 * b})`; // 把颜色值赋给 ctx.fillStyle
  ctx.beginPath();
  ctx.rect(20, -20, 480 * p, 40);
  ctx.fill();
  window.ctx = ctx;
  requestAnimationFrame(update);
}
update(0);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

上面这段代码的实现效果可以看这里 (opens new window)

# 不同颜色表示法的使用场景

在可视化应用里,一般有两种使用颜色的方式:

  • 第一种,整个项目的 UI 配色全部由 UI 设计师设计好,提供给可视化工程师使用。那在这种情况下,设计师设计的颜色是多少就是多少,开发者使用任何格式的颜色都行。

  • 第二种方式就是根据数据情况由前端动态地生成颜色值。当然不会是整个项目都由开发者完全自由选择,而一般是由设计师定下视觉基调和一些主色,开发者根据主色和数据来生成对应的颜色。

在一般的图表呈现项目中,第一种方式使用较多。而在一些数据比较复杂的项目中,我们经常会使用第二种方式。

尤其是当我们希望连续变化的数据能够呈现连续的颜色变换时,设计师就很难用预先指定的有限的颜色来表达了。这时候,我们就需要使用其他的方式,比如,HLS、CIELab 或者 Cubehelix 色盘,我们会把它们结合数据变量来动态生成颜色值。

# 更多学习资料

# 图案生成:如何生成重复图案、分形图案以及随机效果

# 如何绘制大批量重复图案

在可视化应用中,我们经常会使用重复图案。比如说,我们在显示图表的时候,经常会给背景加上一层网格,这样可以辅助用户阅读和理解图表数据。

# 1. 使用 background-image 来绘制网格线

canvas {
  background-image: linear-gradient(to right, transparent 90%, #ccc 0),
    linear-gradient(to bottom, transparent 90%, #ccc 0);
  background-size: 8px 8px, 8px 8px;
}
1
2
3
4
5

这段代码给 background-image 设置了两个 linear-gradient,一个是横向的(to right),一个是纵向的(to bottom)。因为 css 的 background-repeat 默认值是 repeat,所以我们给背景设置一下 background-size。这样,我们利用浏览器自己的 background-repeat 机制,就可以实现网格背景了。

此处的 linear-gradient 属性定义了线性渐变,to right 表示颜色过渡是从左到右的,其中 0% 到 90% 的区域是透明的,90% 到 100% 的区域是 #ccc 颜色。并且,在 linear-gradient 中定义颜色过渡的时候,如果后一个过渡颜色的区域值和前面相同,可以把它简单写为 0。

因为浏览器将渐变属性视为图片,所以我们可以将渐变设置在任何可以接受图片的 CSS 属性上。在这里,我们就可以把渐变设置在 background-image 上,也就是作为背景色来使用。

# 使用 background-image 的局限性

  • 首先,因为它设置的是 Canvas 元素的背景,所以它和直接绘制在画布上的其他图形就处于不同的层,我们也就没法将它覆盖在这些图形上了。

  • 其次,当我们用坐标变换来缩放或移动元素时,作为元素背景的网格是不会随着缩放或移动而改变的。

# 2. 使用 Shader 来绘制网格线

如果是用 WebGL 来渲染的话,就是利用 GPU 并行计算的特点,使用着色器来绘制背景网格这样的重复图案。

# 简单封装 WebGL 的库:gl-renderer

gl-renderer (opens new window) 在 WebGL 底层的基础上进行了一些简单的封装,以便于我们将重点放在提供几何数据、设置变量和编写 Shader 上,不用因为创建 buffer 等细节而分心。

下面是使用 WebGL 绘制网格线的完整代码,最终效果可以看这里 (opens new window)

// 顶点着色器
const vertex = `
  attribute vec2 a_vertexPosition;
  attribute vec2 uv;

  varying vec2 vUv;

  void main() {
    gl_PointSize = 1.0;
    vUv = uv;
    gl_Position = vec4(a_vertexPosition, 1, 1);
  }
`;
// 片元着色器
const fragment = `
  #ifdef GL_ES
  precision mediump float;
  #endif

  varying vec2 vUv;
  uniform float rows;

  void main() {
    vec2 st = fract(vUv * rows);
    float d1 = step(st.x, 0.9);
    float d2 = step(0.1, st.y);
    gl_FragColor.rgb = mix(vec3(0.8), vec3(1.0), d1 * d2);
    gl_FragColor.a = 1.0;
  }
`;

const canvas = document.querySelector("canvas");
// 第一步:创建 Renderer 对象
const renderer = new GlRenderer(canvas);

// 第二步:创建并启用 WebGL 程序
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

// 第三步:设置 uniform 变量
// 这里设置了一个 rows 变量,表示每一行显示多少个网格
// 因为 rows 决定网格重复的次数,所以最终的效果和 rows 的取值有关
renderer.uniforms.rows = 1;

const rows = [1, 4, 16, 32, 64];
let idx = 0;
const timerId = setInterval(() => {
  renderer.uniforms.rows = rows[idx++];
  if (idx > 4) {
    clearInterval(timerId);
  }
}, 1000);

// 第四步:将顶点数据送入缓冲区
renderer.setMeshData([
  {
    // 顶点坐标,正好覆盖整个 Canvas 画布
    positions: [
      [-1, -1],
      [-1, 1],
      [1, 1],
      [1, -1],
    ],
    attributes: {
      // 纹理坐标,这个坐标系的左下角为 0,0,右上角为 1,1
      uv: [
        [0, 0],
        [0, 1],
        [1, 1],
        [1, 0],
      ],
    },
    // 顶点索引
    // 由于 WebGL 只能渲染经过三角剖分之后的多边形,这两个点将这个矩形画布剖分成两个三角形
    cells: [
      [0, 1, 2],
      [2, 0, 3],
    ],
  },
]);

renderer.render();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82

具体解释下片元着色器的代码。

void main() {
  vec2 st = fract(vUv * rows);
  float d1 = step(st.x, 0.9);
  float d2 = step(0.1, st.y);
  gl_FragColor.rgb = mix(vec3(0.8), vec3(1.0), d1 * d2);
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7

首先,我们要获得重复的 rows 行 rows 列的值 st。这里我们要用到一个函数 fract,它在 Shader 中非常常用,可以用来获取一个数的小数部分。当一个数从 0~1 周期性变化的时候, 我们只要将它乘以整数 N,然后再用 fract 取小数,就能得到 N 个周期的数值。

接着处理 st 的 x 和 y。因为 WebGL 中的片元着色器线性插值,所以现在它们默认是线性变化的,而我们要的是阶梯变化。要实现阶梯变化,我们可以使用 step 函数,step 函数是 Shader 中另一个很常用的函数,它就是一个阶梯函数。它的原理是:当 step(a, b) 中的 b < a 时,返回 0;当 b >= a 时,返回 1。

最后,根据 d1 * d2 的值,决定背景网格使用哪个颜色来绘制。要实现这个目的,我们就要使用到第三个函数 mixmix 是线性插值函数,mix(a, b, c) 表示根据 c 是 0 或 1,返回 a 或者 b。

比如这里,当 st.x 小于 0.9 且 st.y 大于 0.1,也就是 d1 * d2 等于 1 的时候,mix(vec3(0.8), vec3(1.0), d1 * d2) 的结果是 vec3(1.0),也就是白色。否则就是 vec3(0.8),也就是灰色。

# 使用 Shader 的优势

用 Shader 实现重复图案的优势在于,不管我们给 rows 取值多少,图案都是一次绘制出来的,并不会因为 rows 增加而消耗性能。所以,使用 Shader 绘制重复图案,不管绘制多么细腻,图案重复多少次,绘制消耗的时间几乎是常量,不会遇到性能瓶颈

# 如何绘制分形图案

分形不仅是自然界中存在的一种自然现象,也是一种优美的数学模型。通俗点来说,一个分形图案可以划分成无数个部分,而每个部分的形状又 都和这个图案整体具有相似性。所以,典型的分形效果具有局部与整体的自相似性以及无限细节(分形可以无限放大),能产生令人震撼的视觉效果。

绘制分形图案需要用到分形公式,Mandelbrot Set,也叫曼德勃罗特集。它是由美国数学家曼徳勃罗特教授发现的迭代公式构成的分形集合。这个公式中 是复数,C 是一个实数常量。

下面使用这个公式来实现一个分形图案,最终效果可以看这里 (opens new window)

首先实现一个片元着色器,这里设置了初始的 z 和 c,然后执行迭代。理论上曼德勃罗特集应该是无限迭代的,但是我们肯定不能让它无限循环,所以我们要给一个足够精度的最大迭代次数,比如 65536。在迭代过程中,如果 z 的模大于 2,那它就结束计算,否则就继续迭代,直到达到循环次数。

const fragment = `
  #ifdef GL_ES
  precision mediump float;
  #endif

  varying vec2 vUv;
  uniform vec2 center;
  uniform float scale;

  vec2 f(vec2 z, vec2 c) {
    return mat2(z, -z.y, z.x) * z + c;
  }

  void main() {
    vec2 uv = vUv;
    vec2 c = center + 4.0 * (uv - vec2(0.5)) / scale;
    vec2 z = vec2(0.0);
    bool escaped = false;
    int j;
    for (int i = 0; i < 65536; i++) {
      if (i > iterations) break;
      j = i;
      z = f(z, c);
      if (length(z) > 2.0) {
        escaped = true;
        break;
      }
    }
    gl_FragColor.rgb = escaped ? vec3(float(j)) / float(iterations) : vec3(0.0);
    gl_FragColor.a = 1.0;
  }
`;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

接着,把 (0, 0) 设置为图案中心点,放大系数初始设为 1,即原始大小,然后开始渲染。

const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
renderer.uniforms.center = [0, 0];
renderer.uniforms.scale = 1;
renderer.uniforms.iterations = 256;

renderer.setMeshData([{
  positions: [
    [-1, -1],
    [-1, 1],
    [1, 1],
    [1, -1],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);

renderer.render();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 如何给图案增加随机效果

在 Shader 中,要想实现随机效果,可以使用伪随机函数。下面是一个常用的伪随机函数:

float random (vec2 st) {
  return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
1
2
3

这个伪随机函数的原理是,取正弦函数偏后部的小数部分的值来模拟随机。如果我们传入一个确定的 st 值,它就会返回一个符合随机分布的确定的 float 值。

下面这段代码的执行结果是一片噪点。

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  float random (vec2 st) {
    return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
  }

  void main() {
    gl_FragColor.rgb = vec3(random(vUv));
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

如果用 floor 取整函数,就可以生成随机的色块。

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  float random (vec2 st) {
    return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
  }

  void main() {
    vec2 st = vUv * 10.0;
    gl_FragColor.rgb = vec3(random(floor(st)));
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

floor 函数和 JavaScript 的 Math.floor 一样,都是向下取浮点数的整数部分,不过,glsl 的 floor 可以直接对向量使用。我们通过 floor(st) 实际上取到了 0,0 到 9,9,一共 10 行 * 10 列 = 100 个方块。然后再通过 random 函数给每一个方块随机一个颜色,最终实现了一个灰白黑的随机方块矩阵。最终效果可以看这里 (opens new window)

此外,我们还可以结合随机和动态效果。具体的方法就是传入一个代表时间的 uTime 变量,最终效果可以看这里 (opens new window)

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  uniform float uTime;

  float random (vec2 st) {
    return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
  }

  void main() {
    vec2 st = vUv * vec2(100.0, 50.0);

    st.x -= (1.0 + 10.0 * random(vec2(floor(st.y)))) * uTime;

    vec2 ipos = floor(st); // integer
    vec2 fpos = fract(st); // fraction

    vec3 color = vec3(step(random(ipos), 0.7));
    color *= step(0.2,fpos.y);

    gl_FragColor.rgb = color;
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# 绘制迷宫

# 片元着色器入门指南

# 如何使用滤镜函数实现美颜效果

在可视化领域里,我们常常需要处理大规模的数据,比如,需要呈现数万甚至数十万条信息在空间中的分布情况。如果我们用几何绘制的方式将这些信息一一绘制出来,性能可能就会很差。

这时,我们就可以将这些数据简化为像素点进行处理。这种处理图像的新思路就叫做像素化。在可视化应用中,图片像素化处理是一个很重要手段,它能够在我们将原始数据信息转换成图形后,进一步处理图形的细节,突出我们想要表达的信息,还能让视觉呈现更有冲击力。

# 怎么理解像素化和像素处理

所谓像素化,就是把一个图像看成是由一组像素点组合而成的。每个像素点负责描述图像上的一个点,并且带有这个点的基本绘图信息。那对于一张 800 像素宽、600 像素高的图片来说,整张图一共就有 48 万个像素点。

Canvas2D 以 4 个通道来存放每个像素点的颜色信息,每个通道是 8 个比特位,也就是 0 ~ 255 的十进制数值,4 个通道对应 RGBA 颜色的四个值

像素处理实际上就是我们为了达到特定的视觉效果,用程序来处理图像上每个像素点。像素处理的应用非常广泛,能实现的效果也非常多。

# 应用一:实现灰度化图片

# 灰度化图片的实现过程

灰度化图片的过程如下:

首先,我们加载一张图片将它绘制到 canvas,接着我们通过 getImageData 获取 imageData 信息,再通过 imageData.data 遍历图像上的所有像素点,对每个像素点的 RGBA 值进行加权平均处理,然后将处理好的信息回写到 canvas 中去。

# 什么是 imgData

imgData 是我们在像素处理中经常用到的对象,它是一个 ImageData 对象,它有 3 个属性,分别是 width、height 和 data。其中 width 表示剪裁区的宽度属性,height 表示剪裁区的高度属性,data 用来存储图片的全部像素信息。

ImageData 常见操作 (opens new window)

图片的全部像素信息会以类型数组(Uint8ClampedArray)的形式保存在 ImageData 对象的 data 属性里,而类型数组的每 4 个元素组成一个像素的信息,这四个元素依次表示该像素的 RGBA 四通道的值,它的数据结构如下:

data[0] // 第 1 行第 1 列的红色通道值
data[1] // 第 1 行第 1 列的绿色通道值
data[2] // 第 1 行第 1 列的蓝色通道值
data[3] // 第 1 行第 1 列的 Alpha 通道值
data[4] // 第 1 行第 2 列的红色通道值
data[5] // 第 1 行第 2 列的绿色通道值
// ...
1
2
3
4
5
6
7

结合这个结构,可以得出 data 属性的类型数组的总长度:width * height * 4。这是因为图片一共是 width * height 个像素点,每个像素点有 4 个通道,所以总长度是像素点的 4 倍。

# 灰度化加权平均公式

对图片每个像素点的 RGB 通道值进行加权平均的方法参考以下公式:

其中 R、G、B 是原图片中的 R、G、B 通道的色值,V 是加权平均色值,a、b、c 是加权系数,满足 (a + b + c) = 1。

# 灰度化图片的实现代码

const canvas = document.getElementById('paper');
const context = canvas.getContext('2d');
(async function () {
  // 异步加载图片
  const img = await loadImage('assets/girl1.jpg');
  // 获取图片的 imageData 数据对象
  const imageData = getImageData(img);
  // 遍历 imageData 数据对象
  traverse(imageData, ({ r, g, b, a }) => { // 对每个像素进行灰度化处理
    // 这里之所以用这三个不同的权重,是因为人的视觉对 R、G、B 三色通道的敏感度是不一样的
    // 对绿色敏感度高,所以加权值高,对蓝色敏感度低,所以加权值低
    const v = 0.2126 * r + 0.7152 * g + 0.0722 * b;
    return [v, v, v, a];
  });
  // 更新 canvas 内容
  canvas.width = imageData.width;
  canvas.height = imageData.height;
  context.putImageData(imageData, 0, 0);
}());
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 应用二:使用像素矩阵通用地改变像素颜色

# 改变图片亮度的公式

要想实现改变图片的亮度,我们可以将 R、G、B 通道的值都乘以一个常量 p,公式如下:

这里的 p 是一个常量,如果它小于 1,那么 R、G、B 值就会变小,图片就会变暗,也就更接近于黑色了。相反,如果 p 大于 1,图片就会变亮,更接近白色。

然而,如果像这样每次想要实现不同的颜色变换,就得使用不同的方程组的话,会非常麻烦。我们可以引入一个颜色矩阵,它能够处理几乎所有的颜色变换类滤镜。

# 颜色矩阵原理

我们创建一个 4 * 5 颜色矩阵,让它的第一行决定红色通道,第二行决定绿色通道,第三行决定蓝色通道,第四行决定 Alpha 通道。

如果要改变一个像素的颜色效果,我们只需要将该矩阵与像素的颜色向量相乘就可以了。

# 灰度化颜色矩阵

那么灰度化图片的处理过程,就可以描述成如下的颜色矩阵:

function grayscale(p = 1) {
  const r = 0.2126 * p;
  const g = 0.7152 * p;
  const b = 0.0722 * p;
  return [
    r + 1 - p, g, b, 0, 0,
    r, g + 1 - p, b, 0, 0,
    r, g, b + 1 - p, 0, 0,
    0, 0, 0, 1, 0,
  ];
}
1
2
3
4
5
6
7
8
9
10
11

这里引入了一个参数 p,它是一个 0 ~ 1 的值,表示灰度化的程度,1 是完全灰度化,0 是完全不灰度,也就是保持原始色彩。这样一来,通过调节 p 的值就可以改变图片灰度化的程度。因此这个灰度化矩阵,比前面直接用灰度化公式更加通用。

但是,光有颜色矩阵还不行,要想实现不同的颜色变化,根据前面的公式,我们还得让旧的色值与颜色矩阵相乘,把新的色值计算出来。

由于我们可以将多次颜色变换的过程,简化为将相应的颜色矩阵相乘,然后用最终的那个矩阵对颜色值进行映射。所以这里不仅提供了处理色值映射(将 RGBA 颜色通道组成的向量与颜色矩阵相乘得到新色值)的 transformColor,还提供了一个矩阵乘法的 multiply 方法。两个方法的具体实现看这里 (opens new window)

具体的流程图如下:

这样,灰度化图片的核心代码就可以写成:

// ...
traverse(imageData, ({ r, g, b, a }) => {
  return transformColor([r, g, b, a], grayscale(1));
});
// ...
1
2
3
4
5

像 grayscale 这种返回颜色矩阵的函数一般称为颜色滤镜函数。抽象出了颜色滤镜函数之后,我们处理颜色代码的过程就可以规范成如下图所示的过程:

# 像素处理相关工具函数

像素处理工具函数 (opens new window)

# 各种颜色滤镜函数

颜色滤镜函数就是指返回颜色矩阵的函数。

function clamp(value, min, max) {
  if(min > max) [min, max] = [max, min];
  if(value < min) return min;
  if(value > max) return max;
  return value;
}

// 灰度化
export function grayscale(p) {
  p = clamp(0, 1, p);
  const r = 0.212 * p;
  const g = 0.714 * p;
  const b = 0.074 * p;

  return [
    r + 1 - p, g, b, 0, 0,
    r, g + 1 - p, b, 0, 0,
    r, g, b + 1 - p, 0, 0,
    0, 0, 0, 1, 0,
  ];
}

// 改变亮度
// p = 0 全暗,p > 0 且 p < 1 调暗,p = 1 原色,p > 1 调亮
export function brightness(p) {
  return [
    p, 0, 0, 0, 0,
    0, p, 0, 0, 0,
    0, 0, p, 0, 0,
    0, 0, 0, 1, 0,
  ];
}

// 饱和度,与 grayscale 正好相反
// p = 0 完全灰度化,p = 1 原色,p > 1 增强饱和度
export function saturate(p) {
  // p = clamp(0, 1, p);
  const r = 0.212 * (1 - p);
  const g = 0.714 * (1 - p);
  const b = 0.074 * (1 - p);
  return [
    r + p, g, b, 0, 0,
    r, g + p, b, 0, 0,
    r, g, b + p, 0, 0,
    0, 0, 0, 1, 0,
  ];
}

// 对比度
// p = 1 原色, p < 1 减弱对比度,p > 1 增强对比度
export function contrast(p) {
  const d = 0.5 * (1 - p);
  return [
    p, 0, 0, 0, d,
    0, p, 0, 0, d,
    0, 0, p, 0, d,
    0, 0, 0, 1, 0,
  ];
}

// 反色
// p = 0 原色,p = 1 完全反色
export function invert(p) {
  const d = 1 - 2 * p;
  return [
    d, 0, 0, 0, p,
    0, d, 0, 0, p,
    0, 0, d, 0, p,
    0, 0, 0, 1, 0,
  ];
}

export function sepia(p) {
  return [
    1 - 0.607 * p, 0.769 * p, 0.189 * p, 0, 0,
    0.349 * p, 1 - 0.314 * p, 0.168 * p, 0, 0,
    0.272 * p, 0.534 * p, 1 - 0.869 * p, 0, 0,
    0, 0, 0, 1, 0,
  ];
}

// 透明度
// p = 0 全透明,p = 1 原色
export function opacity(p) {
  return [
    1, 0, 0, 0, 0,
    0, 1, 0, 0, 0,
    0, 0, 1, 0, 0,
    0, 0, 0, p, 0,
  ];
}

// 过滤或增强某个颜色通道
export function channel({ r = 1, g = 1, b = 1 }) {
  return [
    r, 0, 0, 0, 0,
    0, g, 0, 0, 0,
    0, 0, b, 0, 0,
    0, 0, 0, 1, 0,
  ];
}

// 色相旋转,将色调沿极坐标转过 deg 角度
export function hueRotate(deg) {
  const rotation = deg / 180 * Math.PI;
  const cos = Math.cos(rotation),
    sin = Math.sin(rotation),
    lumR = 0.213,
    lumG = 0.715,
    lumB = 0.072;
  return [
    lumR + cos * (1 - lumR) + sin * (-lumR), lumG + cos * (-lumG) + sin * (-lumG), lumB + cos * (-lumB) + sin * (1 - lumB), 0, 0,
    lumR + cos * (-lumR) + sin * (0.143), lumG + cos * (1 - lumG) + sin * (0.140), lumB + cos * (-lumB) + sin * (-0.283), 0, 0,
    lumR + cos * (-lumR) + sin * (-(1 - lumR)), lumG + cos * (-lumG) + sin * (lumG), lumB + cos * (1 - lumB) + sin * (lumB), 0, 0,
    0, 0, 0, 1, 0,
  ];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117

在实际工作中为了实现更多样的效果,我们经常需要叠加使用多种滤镜函数。上面封装的 transformColor 函数,就完全考虑到了应用多个滤镜函数的需求,它的参数可以接受多个矩阵,如果传给它多个矩阵,它会将每个矩阵一一进行乘法运算。

比如下面这段代码同时叠加了三种滤镜函数。

traverse(imageData, ({ r, g, b, a }) => {
  return transformColor(
    [r, g, b, a],
    channel({ r: 1.2 }), // 增强红色通道
    brightness(1.2), // 增强亮度
    saturate(1.2), // 增强饱和度
  );
})
1
2
3
4
5
6
7
8

# 应用三:使用高斯模糊对照片美颜

# 高斯模糊

高斯模糊的原理与颜色滤镜不同,高斯模糊不是单纯根据颜色矩阵计算当前像素点的颜色值,而是会按照高斯分布的权重,对当前像素点及其周围像素点的颜色按照高斯分布的权重加权平均。

这样做,我们就能让图片各像素色值与周围色值的差异减小,从而达到平滑,或者说是模糊的效果。所以,高斯模糊是一个非常重要的平滑效果滤镜(Blur Filters)。

高斯模糊的算法 (opens new window)

# 高斯分布的原理

高斯分布的原理就是正态分布,简单来说就是将当前像素点的颜色值设置为附近像素点颜色值的加权平均,而距离当前像素越近的点的权重越高,权重分布满足正态分布。

# 二维高斯函数公式

这个公式其实就对应这几句代码:

const a = 1 / (Math.sqrt(2 * Math.PI) * sigma);
const b = -1 / (2 * sigma ** 2);
const g = a * Math.exp(b * x ** 2);
1
2
3

# 高斯模糊算法的实现

高斯模糊的算法分两步:

  • 第一步是生成高斯分布矩阵,这个矩阵的作用是按照高斯函数提供平滑过程中参与计算的像素点的加权平均权重。
function gaussianMatrix(radius, sigma = radius / 3) {
  const a = 1 / (Math.sqrt(2 * Math.PI) * sigma);
  const b = -1 / (2 * sigma ** 2);
  let sum = 0;
  const matrix = [];
  for(let x = -radius; x <= radius; x++) {
    const g = a * Math.exp(b * x ** 2);
    matrix.push(g);
    sum += g;
  }

  for(let i = 0, len = matrix.length; i < len; i++) {
    matrix[i] /= sum;
  }
  return {matrix, sum};
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  • 第二步,对图片在 x 轴、y 轴两个方向上分别进行高斯运算。也就是沿着图片的宽、高方向对当前像素和它附近的像素,应用上面得出的权重矩阵中的值进行加权平均。

/**
  * 高斯模糊
  * @param  {Array} pixes  pix array
  * @param  {Number} width 图片的宽度
  * @param  {Number} height 图片的高度
  * @param  {Number} radius 取样区域半径, 正数, 可选, 默认为 3.0
  * @param  {Number} sigma 标准方差, 可选, 默认取值为 radius / 3
  * @return {Array}
  */
export function gaussianBlur(pixels, width, height, radius = 3, sigma = radius / 3) {
  const {matrix, sum} = gaussianMatrix(radius, sigma);
  // x 方向一维高斯运算
  for(let y = 0; y < height; y++) {
    for(let x = 0; x < width; x++) {
      let r = 0,
        g = 0,
        b = 0;

      for(let j = -radius; j <= radius; j++) {
        const k = x + j;
        if(k >= 0 && k < width) {
          const i = (y * width + k) * 4;
          r += pixels[i] * matrix[j + radius];
          g += pixels[i + 1] * matrix[j + radius];
          b += pixels[i + 2] * matrix[j + radius];
        }
      }
      const i = (y * width + x) * 4;
      // 除以 sum 是为了消除处于边缘的像素, 高斯运算不足的问题
      pixels[i] = r / sum;
      pixels[i + 1] = g / sum;
      pixels[i + 2] = b / sum;
    }
  }

  // y 方向一维高斯运算
  for(let x = 0; x < width; x++) {
    for(let y = 0; y < height; y++) {
      let r = 0,
        g = 0,
        b = 0;

      for(let j = -radius; j <= radius; j++) {
        const k = y + j;
        if(k >= 0 && k < height) {
          const i = (k * width + x) * 4;
          r += pixels[i] * matrix[j + radius];
          g += pixels[i + 1] * matrix[j + radius];
          b += pixels[i + 2] * matrix[j + radius];
        }
      }
      const i = (y * width + x) * 4;
      pixels[i] = r / sum;
      pixels[i + 1] = g / sum;
      pixels[i + 2] = b / sum;
    }
  }
  return pixels;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

# CSS 滤镜和 Canvas 滤镜

上面的各种滤镜效果,我们都是自己通过 Canvas 的 getImageData 方法拿到像素数据,然后遍历读取或修改像素信息实现的。

实际上,如果只是按照某些特定规则改变一个图像上的所有像素,浏览器提供了更简便的方法,那就是 CSS 滤镜。

// 灰度化图片
<img src="https://p2.ssl.qhimg.com/d/inn/4b7e384c55dc/girl1.jpg" style="filter:grayscale(100%)" />

// 美颜效果
<img src="https://p0.ssl.qhimg.com/t01161037b5fe87f236.jpg" style="filter:blur(1.5px) grayscale(0.5) saturate(1.2) contrast(1.1) brightnes(1.2)" />
1
2
3
4
5

此外,比较新的浏览器上还实现了原生的 Canvas 滤镜,与 CSS 滤镜相对应。CSS 滤镜和 Canvas 滤镜都能实现非常丰富的滤镜效果,在处理视觉呈现上很有用。

不过,尽管 CSS 滤镜 (opens new window)Canvas 滤镜 (opens new window)都很好用,但是在实现效果上都有局限性,它们一般只能实现比较固定的视觉效果。这对于可视化来说,这并不够用。

这个时候像素处理的优势就体现出来了,用像素处理图片更灵活,因为它可以实现滤镜功能,还可以实现更加丰富的效果,包括一些非常炫酷的视觉效果,这正是可视化领域所需要的。

# 如何给简单的图案添加纹理和复杂滤镜

颜色滤镜是基本的简单滤镜因为简单滤镜里的每个像素都是独立的,所以它的处理结果不依赖于其他像素点的信息,因此应用起来也比较简单。

高斯滤镜也就是平滑效果滤镜,它是最基本的复杂滤镜复杂滤镜的处理结果不仅与当前像素有关,还与其周围的像素点有关,所以应用起来很复杂。

不过,颜色滤镜和高斯滤镜能够实现的视觉效果有限。如果想要实现更复杂的视觉效果,我们还需要使用更多其他的滤镜。

# 实现图片边缘模糊效果

实现思路就是,在遍历像素点的时候计算当前像素点到图片中心点的距离,然后根据距离设置透明度。

# 实现图片融合

这种能叠加到其他照片上的图片,通常被称为纹理(Texture),叠加后的效果也叫做纹理效果

纹理叠加能实现的效果非常多,所以它也是像素处理中的基础操作。不过,不管我们是用 Canvas 的 ImageData API 处理像素、应用滤镜还是纹理合成都有一个弊端,那就是我们必须循环遍历图片上的每个像素点。如果这个图片很大,比如它是 2000px 宽、2000px 高,我们就需要遍历 400 万像素!这个计算量是相当大的。

由于我们前面生成的都只是静态的图片效果,所以这个计算量的问题还不明显。一旦我们想要利用像素处理,制作出更酷炫的动态效果,这样的计算量注定会成为性能瓶颈。

解决这个问题的方法就是用 WebGL 这个神器。WebGL 通过运行着色器代码来完成图形的绘制和输出。其中,片元着色器负责处理像素点的颜色。

# 片元着色器是怎么处理像素的

如果想要在片元着色器中处理像素,我们需要先将图片的数据信息读取出来,交给 WebGL 程序来处理,这样我们就可以在着色器中处理了。

在 WebGL 中,我们会使用特殊的一种对象,叫做纹理对象(Texture)。纹理对象包括了整张图片的所有像素点的颜色信息。

我们将纹理对象作为一种特殊格式的变量,通过 uniform 传递给着色器,这样就可以在着色器中处理了。在着色器中,我们可以通过纹理坐标来读取对应的具体坐标处像素的颜色信息

纹理坐标是一个变量,类型是二维向量,x、y 的值从 0 到 1。这个变量就是前面出现过的传给顶点着色器的 uv 属性,对应片元着色器中的 vUv 变量。

总的来说,着色器中加载纹理对象 (opens new window)的过程如下:

先通过图片或者 Canvas 对象来创建纹理对象,然后通过 uniform 变量把它传入着色器,最后再通过纹理坐标 vUv 就可以从加载的纹理对象上获取颜色信息。

创建纹理对象这个步骤比较复杂,因为设置不同的参数可以改变我们在 Shader 中对纹理取色的行为,所以其中最复杂的是参数部分。参数设置可以参考这里 (opens new window),也可以参考WebGL 纹理详解 (opens new window)

纹理创建完成之后,还要设置纹理。具体来说就是,通过 gl.activeTexture 将对象绑定到纹理单元,再把纹理单元编号通过 uniform 写入 shader 变量中。

总之,在 WebGL 中,从创建纹理、设置纹理到使用纹理的步骤非常多,如果全都要自己实现的话非常麻烦。可以直接使用 gl-renderer (opens new window) 这个库。

经过 gl-renderer 库的封装之后,我们通过 renderer.loadTexture 就可以创建并加载纹理,然后直接将纹理对象本身作为 renderer 的 uniforms 属性值即可,就不用去关注其他细节了。

const texture = await renderer.loadTexture(imgURL);
renderer.uniforms.tMap = texture;
1
2

# 纹理实现灰度化图片

首先,之前用 Canvas2D 实现灰度化图片时的矩阵是 4 * 5 的矩阵,如下:

function grayscale(p = 1) {
  const r = 0.2126 * p;
  const g = 0.7152 * p;
  const b = 0.0722 * p;
  return [
    r + 1 - p, g, b, 0, 0,
    r, g + 1 - p, b, 0, 0,
    r, g, b + 1 - p, 0, 0,
    0, 0, 0, 1, 0,
  ];
}
1
2
3
4
5
6
7
8
9
10
11

但是因为 GLSL 语法在数据类型上不能直接支持 mat4 以上的矩阵,所以我们要计算 4 * 5 矩阵很不方便。而且在通常情况下,我们不经常处理颜色的 alpha 值,所以这里我就把 alpha 通道忽略了,只对 RGB 做矩阵变换,这样我们用 mat4 的齐次矩阵就够了。

其次,根据标准的矩阵与向量乘法的法则,应该是向量与矩阵的列相乘,所以我们把这次传入的矩阵转置了一下,把按行排列的 rgba 换成按列排列,就得到了下面这个矩阵。

renderer.uniforms.colorMatrix = [
  r, r, r, 0,
  g, g, g, 0,
  b, b, b, 0,
  0, 0, 0, 1,
];
1
2
3
4
5
6

# 纹理实现图片粒子化

# 纹理实现图像合成

这些用片元着色器实现的滤镜效果,在性能上要远远高于 Canvas2D。

# 如何使用片元着色器进行几何造型

在 WebGL 中,片元着色器有着非常强大的能力,它能够并行处理图片上的全部像素,让数以百万计的运算同时完成。但也正因为它是并行计算的,所以它和常规代码顺序执行或者串行执行过程并不一样。因此,在使用片元着色器实现某些功能的时候,我们要采用与常规的 JavaScript 代码不一样的思路。

# 如何用片元着色器控制局部颜色

片元着色器能够用来控制像素颜色,最简单的就是把图片绘制为纯色。比如,通过下面的代码,我们就把一张图片绘制为了纯黑色。

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  void main() {
    gl_FragColor = vec4(0, 0, 0, 1);
  }
`
1
2
3
4
5
6
7
8
9
10
11

如果想让一张图片呈现不同的颜色,我们还可以根据纹理坐标值来绘制,比如,通过下面的代码,我们就可以让某个图案的颜色,从左到右由黑向白过渡。

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  void main() {
    gl_FragColor.rgb = vec3(vUv.x);
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12

不过,这种颜色过渡还比较单一,这里我们还可以改变一下渲染方式让图形呈现的效果更复杂。比如说,我们可以使用乘法创造一个 10 * 10 的方格,让每个格子左上角是绿色,右下角是红色,中间是过渡色。

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  void main() {
    vec2 st = vUv * 10.0;
    gl_FragColor.rgb = vec3(fract(st), 0.0);
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13

此外,还可以在上图的基础上继续做调整。我们可以通过 idx = floor(st) 获取网格的索引,判断网格索引除以 2 的余数(奇偶性),根据它来决定是否翻转网格内的 x、y 坐标。最终效果 (opens new window)

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  void main() {
    vec2 st = vUv * 10.0;
    vec2 idx = floor(st);
    vec2 grid = fract(st);
    vec2 t = mod(idx, 2.0);
    if (t.x == 1.0) {
      grid.x = 1.0 - grid.x;
    }
    if (t.y == 1.0) {
      grid.y = 1.0 - grid.y;
    }
    gl_FragColor.rgb = vec3(grid, 0.0);
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 用片元着色器绘制圆

一般来说,我们画圆的时候是根据点坐标到圆心的距离来生成颜色的。在片元着色器中,我们可以用 distance 函数求一下 vUv 和画布中点 vec2(0.5) 的距离,然后根据这个值设置颜色。

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  void main() {
    float d = distance(vUv, vec2(0.5));
    gl_FragColor.rgb = d * vec3(1.0);
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13

不过这样只能绘制一个很模糊的圆。这是因为越靠近圆心,距离 d 的值越小,gl_FragColor.rgb = d * vec3(1.0); 的颜色值也就越接近于黑色。

如果想要画一个更清晰的圆,可以使用 step 函数基于 0.2 做阶梯,就能得到一个半径为 0.2 的圆。

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  void main() {
    float d = distance(vUv, vec2(0.5));
    gl_FragColor.rgb = step(d, 0.2) * vec3(1.0);
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13

但是,会发现绘制出来的这个圆边缘很不光滑,这是因为浮点数计算的精度导致的锯齿现象。为了解决这个问题,我们可以用 smoothstep 代替 step

因为 smoothstep 和 step 类似,都是阶梯函数。但是,与 step 的值是直接跳跃的不同,smoothstep 在 step-start 和 step-end 之间有一个平滑过渡的区间。因此,用 smoothstep 绘制的圆,边缘就会有一圈颜色过渡,就能从视觉上消除锯齿。

片元着色器绘制的圆,在构建图像的粒子效果中比较常用。比如,我们可以用它来实现图片的渐显渐隐效果 (opens new window)

# 用片元着色器绘制直线

用片元着色器绘制直线的原理就是根据点到直线(向量)的距离来设置颜色。最终效果 (opens new window)

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  void main() {
    vec3 line = vec3(1, 1, 0); // 用向量表示所在直线,因为要绘制的是 2D 图形,所以 z 保持 0 就行,而 x 和 y 用来决定方向
    float d = abs(cross(vec3(vUv,0), normalize(line)).z); // 叉乘表示平行四边形面积,底边为 1,得到距离 d,因为 d 带符号,所以取绝对值
    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
    gl_FragColor.a = 1.0;
  }
`;
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在此基础上还可以在着色器代码中再添加两个 uniform 变量,用来实现更复杂的效果。

  • uniform 变量 uMouse 用来实现用鼠标控制线;

  • uniform 变量 uOrigin 用来表示直线经过的固定点。

最终效果看这里 (opens new window)

# 用片元着色器绘制线段

用片元着色器绘制线段的原理就是根据点到线段的距离来设置颜色。

但是因为点和线段之间有两种关系,一种是点在线段上,另一种是在线段之外。所以我们在求点到线段的距离 d 的时候,要分两种情况讨论:

  • 当点到线段的投影位于线段两个端点中间的时候,它就等于点到直线的距离;

  • 当点到线段的投影在两个端点之外的时候,它就等于这个点到最近一个端点的距离。

根据以上原理,可以在原本片元着色器代码的基础上,抽象出一个 seg_distance 函数,用来返回点到线段的距离。

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;
  uniform vec2 uMouse;
  uniform vec2 uOrigin;

  float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
    vec3 ab = vec3(b - a, 0);
    vec3 p = vec3(st - a, 0);
    float l = length(ab);
    float d = abs(cross(p, normalize(ab)).z);
    float proj = dot(p, ab) / l;
    if (proj >= 0.0 && proj <= l) return d;
    return min(distance(st, a), distance(st, b));
  }
  void main() {
    float d = seg_distance(vUv, uOrigin, uMouse);
    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 用片元着色器绘制三角形

片元着色器绘制三角形的方法如下:

  • 首先,我们要判断点是否在三角形内部。我们知道,点到三角形三条边的距离有三个,只要这三个距离的符号都相同,我们就能确定点在三角形内。

  • 然后,我们建立三角形的距离模型。我们规定它的内部距离为负,外部距离为正,并且都选点到三条边的最小距离。

最终效果看这里 (opens new window)

实际上,三角形的这种画法还可以推广到任意凸多边形。比如,矩形和正多边形就可以使用同样的方式来绘制。

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  float line_distance(in vec2 st, in vec2 a, in vec2 b) {
    vec3 ab = vec3(b - a, 0);
    vec3 p = vec3(st - a, 0);
    float l = length(ab);
    return cross(p, normalize(ab)).z;
  }

  float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
    vec3 ab = vec3(b - a, 0);
    vec3 p = vec3(st - a, 0);
    float l = length(ab);
    float d = abs(cross(p, normalize(ab)).z);
    float proj = dot(p, ab) / l;
    if (proj >= 0.0 && proj <= l) return d;
    return min(distance(st, a), distance(st, b));
  }

  float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
    float d1 = line_distance(st, a, b);
    float d2 = line_distance(st, b, c);
    float d3 = line_distance(st, c, a);

    if (d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
      return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
    }
        
    return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
  }

  void main() {
    float d = triangle_distance(vUv, vec2(0.3), vec2(0.5, 0.7), vec2(0.7, 0.3));
    gl_FragColor.rgb = (1.0 - smoothstep(0.0, 0.01, d)) * vec3(1.0);
    gl_FragColor.a = 1.0;
  }
`;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

# 片元着色器绘图方法论:符号距离场渲染

前面绘制的图形虽然各不相同,但是它们的绘制步骤都可以总结为以下两步。

第一步:定义距离。这里的距离,是一个人为定义的概念。在画圆的时候,它指的是点到圆心的距离;在画直线和线段的时候,它是指点到直线或某条线段的距离;在画几何图形的时候,它是指点到几何图形边的距离。

第二步:根据距离着色。首先是用 smoothstep 方法,选择某个范围的距离值,比如在画直线的时候,我们设置 smoothstep(0.0, 0.01, d),就表示选取距离为 0.0 到 0.01 的值。然后对这个范围着色,我们就可以将图形的边界绘制出来了。

符号距离场渲染(Signed Distance Fields Rendering)本质上就是利用空间中的距离分布来着色的。

利用这个方法论可以绘制出很多有趣的图形,下面是一些例子:

# 着色器绘制几何图形的用途

着色器造型是着色器的一种非常基础的使用方法,甚至可以说是图形学中着色器渲染最基础的原理,就好比代数的基础是四则运算一样,它构成了 GPU 视觉渲染的基石,我们在视觉呈现中生成的各种细节特效的方法,万变不离其宗,基本上都和着色器造型有关。

所以我们不仅仅要了解它的用途,更要彻底弄明白它的原理和思路,尤其是非常重要的符号距离场渲染技巧,一定要理解并熟练掌握。

下面是着色器绘制几何图形的几个用途:

# 如何用极坐标系绘制有趣图案

在图形学中,除了直角坐标系之外,还有一种比较常用的坐标系就是极坐标系

极坐标系是一个二维坐标系。与二维直角坐标系使用 x、y 分量表示坐标不同,极坐标系使用相对极点的距离,以及与 x 轴正向的夹角来表示点的坐标,如(3,60°)。

在图形学中,极坐标的应用比较广泛,它不仅可以简化一些曲线方程,甚至有些曲线只能用极坐标来表示。

# 简单的坐标系转换函数

不过,虽然用极坐标可以简化许多曲线方程,但最终渲染的时候,还是需要转换成图形系统默认支持的直角坐标才可以进行绘制。两个坐标系具体转换比较简单,我们可以用两个简单的函数,toPolar 和 fromPolar 来实现,如下:

// 直角坐标映射为极坐标
function toPolar(x, y) {
  const r = Math.hypot(x, y);
  const θ= Math.atan2(y, x);
  return [r, θ];
}

// 极坐标映射为直角坐标
function fromPolar(r, θ) {
  const x = r * cos(θ);
  const y = r * sin(θ);
  return [x, y];
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 用极坐标方程绘制曲线

在前面,我们使用 parametric 函数实现了一个参数方程的绘图模块。如下:

export function parametric(xFunc, yFunc) {
  return function (start, end, seg = 100, ...args) {
    const points = [];
    for (let i = 0; i <= seg; i++) {
      const p = i / seg;
      const t = start * (1 - p) + end * p;
      const x = xFunc(t, ...args); // 计算参数方程组的 x
      const y = yFunc(t, ...args); // 计算参数方程组的 y
      points.push([x, y]);
  }
  return {
    draw: draw.bind(null, points),
    points,
  };
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

在使用极坐标方程绘制曲线的时候,我们也可以用这个 parametric 函数。不过,在使用之前,我们还要对它进行扩展,让它支持坐标映射。这样,我们就可以写出对应的坐标映射函数,从而将极坐标映射为绘图需要的直角坐标了。

具体的方法是,给 parametric 增加一个参数 rFunc。rFunc 是一个坐标映射函数,通过它我们可以将任意坐标映射为直角坐标,如下:

export function parametric(sFunc, tFunc, rFunc) {
  return function (start, end, seg = 100, ...args) {
    const points = [];
    for (let i = 0; i <= seg; i++) {
      const p = i / seg;
      const t = start * (1 - p) + end * p;
      const x = sFunc(t, ...args);
      const y = tFunc(t, ...args);
      if(rFunc) {
        points.push(rFunc(x, y));
      } else {
        points.push([x, y]);
      }
    }
    return {
      draw: draw.bind(null, points),
      points,
    };
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

虽然前面已经给出了足够简单的直角坐标和极坐标的转换函数,但是它们不够灵活,也不便于扩展。这里先使用 rFunc 来抽象坐标映射,再把其他函数作为 rFunc 参数传给 parametric,是一种更通用的坐标映射方法,它属于函数式编程思想

总的来说,使用极坐标系中参数方程来绘制曲线的方法,其实和在直角坐标系中参数方程绘制曲线差不多,唯一的区别就是在具体实现的时候,我们需要额外增加一个坐标映射函数,将极坐标转为直角坐标才能完成最终的绘制

# 用极坐标参数方程画圆

const fromPolar = (r, θ) => {
  return [r * Math.cos(θ), r * Math.sin(θ)];
};

const arc = parametric(
  t => 200,
  t => t,
  fromPolar,
);

arc(0, Math.PI).draw(ctx);
1
2
3
4
5
6
7
8
9
10
11

# 用极坐标参数方程画玫瑰线、心形线和双纽线

# 使用片元着色器与极坐标系绘制圆

下面这段代码绘制了一个圆,最终效果看这里 (opens new window)

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;

  vec2 polar(vec2 st) {
    return vec2(length(st), atan(st.y, st.x));
  }

  void main() {
    vec2 st = vUv - vec2(0.5);
    st = polar(st);
    gl_FragColor.rgb = smoothstep(st.x, st.x + 0.01, 0.2) * vec3(1.0);
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

这里使用的是 GLSL 内置的 float atan(float, float) 方法,对应的方法是 Math.atan,而在 JavaScript 版本的 toPolar 函数中,对应的方法是 Math.atan2。

st = polar(st); 就是将像素坐标转换为极坐标,转换后的 st.x 实际上是极坐标的 r 分量,而 st.y 就是极坐标的 θ 分量

对于极坐标下过极点的圆,实际上的 r 值就是一个常量值,对应圆的半径,所以这里取 smoothstep(st.x, st.x + 0.01, 0.2),就能得到一个半径为 0.2 的圆了。这一步用的就是之前的距离场方法。只不过,在直角坐标系下,点到圆心的距离 d 需要用 x、y 平方和的开方来计算,而在极坐标下,点的极坐标 r 值正好表示了点到圆心的距离 d,所以计算起来就比直角坐标系简单了很多。

# 使用片元着色器与极坐标系绘制玫瑰线

绘制三瓣玫瑰线,最终效果看这里 (opens new window)

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = 0.5 * cos(st.y * 3.0) - st.x;
  gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7

为什么 d = 0.5 * cos(st.y * 3.0) - st.x; 绘制出的图形就是三瓣玫瑰线的图案呢?

这是因为玫瑰线的极坐标方程 r = a * cos(k * θ),所以玫瑰线上的所有点都满足 0 = a * cos(k * θ) - r 这个方程式。

如果我们再把它写成距离场的形式:d = a * cos(k * θ) - r。这个时候就有三种情况:玫瑰线上点的 d 等于 0;玫瑰线围出的图形外的点的 d 小于 0;玫瑰线围出的图形内的点的 d 大于 0。

smoothstep(-0.01, 0.01, d) 能够将 d >= 0,也就是玫瑰线内的点选出来,这样也就绘制出了三瓣图形。

玫瑰线是一种很有趣图案,我们只要修改 u_k 的值,并且保证它是正整数,就可以绘制出不同瓣数的玫瑰线图案。比如两瓣玫瑰线 (opens new window)

比如下面的代码可以绘制出瓣数不断增加的玫瑰线图案。

uniform float u_k;

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = 0.5 * cos(st.y * u_k) - st.x;
  gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
  gl_FragColor.a = 1.0;
}

renderer.uniforms.u_k = 3;

setInterval(() => {
  renderer.uniforms.u_k += 2;
}, 200);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 使用片元着色器与极坐标系绘制花瓣线

花瓣线和玫瑰线不一样,u_k 的取值不一定要是整数。这让它能绘制出来的图形更加丰富。

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = 0.5 * abs(cos(st.y * u_k * 0.5)) - st.x;
  gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7

当 u_k=3 时,可以得到如下图案:

当 u_k=1.3 时,得到的图案像是一个横放的苹果:

在此基础上,还可以再添加几个 uniform 变量,如 u_scale、u_offset 作为参数,来绘制出更多图形。

varying vec2 vUv;
uniform float u_k;
uniform float u_scale;
uniform float u_offset;
      
void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = u_scale * 0.5 * abs(cos(st.y * u_k * 0.5)) - st.x + u_offset;
  gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12

当 u_k=1.7,u_scale=0.5,u_offset=0.2 时,就能得到一个横置的葫芦图案。最终效果看这里 (opens new window)

如果继续修改 d 的计算方程,还能绘制出其他有趣的图形。比如花苞图案 (opens new window)

# 使用极坐标来绘制曲线方法总结

第一种是用 Cavans 渲染,这时候,我们可以用 parametric 高阶函数,将极坐标参数方程和坐标映射函数 fromPolar 传入,得到绘制曲线的函数,再用它来执行绘制。这样,极坐标系就能实现直角坐标系不太好描述的曲线了,比如,玫瑰线、心形线等等。

第二种是使用 shader 渲染,一般的方法是先将像素坐标转换为极坐标,然后使用极坐标构建距离场并着色。它能实现更多复杂的图案。

# 极坐标系实现角向渐变

除了绘制有趣的图案之外,极坐标的另一个应用是角向渐变(Conic Gradients)。

角向渐变就是以图形中心为轴,顺时针地实现渐变效果。而且新的 CSS Image Values and Replaced Content (opens new window) 标准 level4 已经添加了角向渐变,我们可以使用它来创建一个基于极坐标的颜色渐变。

div.conic {
  width: 150px;
  height: 150px;
  border-radius: 50%;
  background: conic-gradient(red 0%, green 45%, blue);
}
1
2
3
4
5
6

在 WebGL 中,我们可以通过极坐标用片元着色器实现类似的角向渐变效果。最终效果看这里 (opens new window)

void main() {
  vec2 st = vUv - vec2(0.5);
  st = polar(st);
  float d = smoothstep(st.x, st.x + 0.01, 0.2);
  // 将角度范围转换到 0 到 2pi 之间
  if(st.y < 0.0) st.y += 6.28;
  // 计算 p 的值,也就是相对角度,p 取值 0 到 1
  float p = st.y / 6.28;
  if(p < 0.45) {
    // p 取 0 到 0.45 时从红色线性过渡到绿色
    gl_FragColor.rgb = d * mix(vec3(1.0, 0, 0), vec3(0, 0.5, 0), p /  0.45);
  } else {
    // p 超过 0.45 从绿色过渡到蓝色
    gl_FragColor.rgb = d * mix(vec3(0, 0.5, 0), vec3(0, 0, 1.0), (p - 0.45) / (1.0 - 0.45));
  }
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这个效果与 CSS 角向渐变得到的基本上一致,除了 CSS 角向渐变的起始角度是与 Y 轴的夹角,而 shader 是与 X 轴的夹角以外,没有其他的不同。这样,我们就可以在 WebGL 中利用极坐标系实现与 CSS 角向渐变一致的视觉效果了。

# 极坐标绘制 HSV 色轮

色轮可以帮助我们把某种颜色表示法所能表示的所有颜色方便、直观地显示出来。HSV 色轮一般用在颜色可视化和择色交互等场合里。

在 WebGL 中绘制 HSV 色轮的方法就是,只需要将像素坐标转换为极坐标,再除以 2π,就能得到 HSV 的 H 值。然后我们用鼠标位置的 x、y 坐标来决定 S 和 V 的值。最终效果看这里 (opens new window)

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;
  uniform vec2 uMouse;

  vec3 hsv2rgb(vec3 c){
    vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
    rgb = rgb * rgb * (3.0 - 2.0 * rgb);
    return c.z * mix(vec3(1.0), rgb, c.y);
  }

  vec2 polar(vec2 st) {
    return vec2(length(st), atan(st.y, st.x));
  }

  void main() {
    vec2 st = vUv - vec2(0.5);
    st = polar(st);
    float d = smoothstep(st.x, st.x + 0.01, 0.2);
    if(st.y < 0.0) st.y += 6.28;
    float p = st.y / 6.28;
    gl_FragColor.rgb = d * hsv2rgb(vec3(p, uMouse.x, uMouse.y));
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

# 圆柱坐标

极坐标系是二维坐标系,如果我们将极坐标系延 z 轴扩展,可以得到圆柱坐标系。圆柱坐标系是一种三维坐标系,可以用来绘制一些三维曲线,比如螺旋线、圆内螺旋线、费马曲线等等。

因为极坐标系可以和直角坐标系相互转换,所以直角坐标系和圆柱坐标系也可以相互转换,公式如下:

从上面的公式中可以发现,我们只转换了 x、y 的坐标,因为它们是极坐标,而 z 的坐标因为本身就是直角坐标不用转换。因此圆柱坐标系又被称为半极坐标系

# 球坐标

在此基础上,我们还可以进一步将圆柱坐标系转为球坐标系。球坐标系在三维图形绘制、球面定位、碰撞检测等等可视化实现时都很有用。

同样地,圆柱坐标系也可以和球坐标系相互转换,公式如下:

# 如何使用噪声生成复杂的纹理

# 什么是噪声

在之前,我们使用一个离散的二维伪随机函数,随机生成了一片带有噪点的图案。然后,又用取整的技巧,将这个图案局部放大,就呈现出方格状图案。参考这里

在真实的自然界中,这种离散的随机是存在的,比如鸟雀随机地鸣叫,蝉鸣随机地响起再停止,雨滴随机地落在某个位置等等。但随机和连续并存是更常见的情况,比如山脉的走向是随机的,山峰之间的高度又是连续,类似的还有天上的云朵、水流的波纹、被侵蚀的土地等等。

把随机和连续结合起来,用来模拟这些真实自然的图形,就形成了噪声(Noise)。

# 一维噪声函数

因为随机数是离散的,那如果我们对离散的随机点进行插值,可以让每个点之间的值连续过渡。因此,我们用 smoothstep 或者用平滑的三次样条来插值,就可以形成一条连续平滑的随机曲线。

下面通过一个生成折线的小例子来验证下。

首先,我们对 floor(st.x) 取随机数,取出 10 个不同的 d 值,然后把它们绘制出来,就能在画布上呈现出 10 段不连续的线段。

#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;

// 随机函数
float random (float x) {
  return fract(sin(x * 1243758.5453123));
}

void main() {
  vec2 st = vUv - vec2(0.5);
  st *= 10.0;
  float i = floor(st.x);
  float f = fract(st.x);
  
  // d 直接等于随机函数返回值,这样 d 不连续
  float d = random(i);
  // float d = mix(random(i), random(i + 1.0), f);
  // float d = mix(random(i), random(i + 1.0), smoothstep(0.0, 1.0, f));
  // float d = mix(random(i), random(i + 1.0), f * f * (3.0 - 2.0 * f));
  
  gl_FragColor.rgb = (smoothstep(st.y - 0.05, st.y, d) - smoothstep(st.y, st.y + 0.05, d)) * vec3(1.0);
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

然后,用 mix(random(i), random(i + 1.0), f); 替换 random(i),那么这些线段的首尾就会连起来,也就是说我们将得到一段连续的折线。

不过,我们得到的折线虽然连续,但因为这个函数在端点处不可导,所以它不平滑。因此,我们可以改用 mix(random(i), random(i + 1.0), smoothstep(0.0, 1.0, f)); 替换 random(i),或者直接采用三次多项式 mix(random(i), random(i + 1.0), f * f * (3.0 - 2.0 * f)); 来替换 step。这样,我们就得到一条连续并且平滑的曲线了。最终效果看这里 (opens new window)

这就是最终想要的噪声函数了。

# 二维噪声函数

不过,这个函数是一维的,如果要使用二维的,我们还可以把它扩展到二维。这个时候,我们就必须要知道,二维噪声和一维噪声之间的区别。很明显,一维噪声是对两点进行插值的,而二维噪声需要对平面画布上方形区域的四个顶点,分别从 x、y 方向进行两次插值

我们可以把 st 与方形区域的四个顶点(对应四个向量)做插值,这样就能得到二维噪声。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

float random (vec2 st) {
  return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}

// 二维噪声,对 st 与方形区域的四个顶点插值
highp float noise(vec2 st) {
  vec2 i = floor(st);
  vec2 f = fract(st);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(random(i + vec2(0.0,0.0)), random(i + vec2(1.0,0.0)), u.x), 
    mix(random(i + vec2(0.0,1.0)), random(i + vec2(1.0,1.0)), u.x), u.y);
}

void main() {
  vec2 st = vUv * 20.0;
  gl_FragColor.rgb = vec3(noise(st));
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

这段代码可以得到下面这个看起来比较模糊的噪声图案。

# 噪声的应用

这两种噪声应用的实现都采用了噪声结合距离场的实现思路。

# 插值噪声和梯度噪声

前面提到的噪声算法,它的原理是对离散的随机值进行插值,因此它又被称为插值噪声(Value Noise)。插值噪声有一个缺点,就是它的值的梯度不均匀。最直观的表现就是,二维噪声图像有明显的 “块状” 特点,不够平滑

想要解决这个问题,我们可以使用另一种噪声算法,也就是梯度噪声(Gradient Noise)。梯度噪声是对随机的二维向量来插值,而不是一维的随机数。这样我们就能够获得更加平滑的噪声效果。

梯度噪声的代码如下:

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

vec2 random2(vec2 st){
  st = vec2(dot(st,vec2(127.1,311.7)), dot(st,vec2(269.5,183.3)));
  return -1.0 + 2.0 * fract(sin(st) * 43758.5453123);
}

// Gradient Noise by Inigo Quilez - iq/2013
// https://www.shadertoy.com/view/XdXGW8
float noise(vec2 st) {
  vec2 i = floor(st);
  vec2 f = fract(st);
  vec2 u = f * f * (3.0 - 2.0 * f);

  return mix(mix(dot(random2(i + vec2(0.0, 0.0)), f - vec2(0.0, 0.0)),
    dot(random2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x), 
    mix(dot(random2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
    dot(random2(i + vec2(1.0, 1.0) ), f - vec2(1.0,1.0)), u.x), u.y);
}

void main() {
  vec2 st = vUv * 20.0;
  gl_FragColor.rgb = vec3(0.5 * noise(st) + 0.5);
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

下面是两种噪声算法生成的图像对比,左边是插值噪声的效果,右边是梯度噪声的效果。

# 用噪声实现云雾效果

云雾效果的实现原理就是,通过改变噪声范围,然后按照不同权重来叠加的方式创造云雾效果。比如,我们可以将噪声叠加 6 次,然后让它每次叠加的时候范围扩大一倍,但是权重减半。

通过这个新的噪声算法,我们就能生成云雾效果了。让这个噪声配合色相变化,可以创造出非常有趣的图形,比如模拟飞机航拍效果 (opens new window)

# Simplex Noise

这是 Ken Perlin 在 2001 年的 Siggraph 会议上展示的噪声算法。

相比于前面的噪声算法,Simplex Noise 算法有更低的计算复杂度和更少的乘法运算,并且可以用更少的计算量达到更高的维度,而且它制造出的噪声非常自然

Simplex Noise 与插值噪声以及梯度噪声的不同之处在于,它不是对四边形进行插值,而是对三角网格进行插值。与四边形插值相比,三角网格插值需要计算的点更少了,这样自然大大降低了计算量,从而提升了渲染性能。

Simplex Noise 具体的实现思路非常精巧和复杂,其中包含的数学技巧比较高深,进一步学习可以参考 Book of Shaders 的文章 (opens new window)

尽管 Simplex Noise 的原理很巧妙和复杂,但是在 Shader 中实现 Simplex Noise 代码并不算太复杂,下面的代码先记住,在需要的时候直接拿来使用。

vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0)*x); }

//
// Description : GLSL 2D simplex noise function
//      Author : Ian McEwan, Ashima Arts
//  Maintainer : ijm
//     Lastmod : 20110822 (ijm)
//     License :
//  Copyright (C) 2011 Ashima Arts. All rights reserved.
//  Distributed under the MIT License. See LICENSE file.
//  https://github.com/ashima/webgl-noise
//
float noise(vec2 v) {
  // Precompute values for skewed triangular grid
  const vec4 C = vec4(0.211324865405187,
    // (3.0-sqrt(3.0))/6.0
    0.366025403784439,
    // 0.5*(sqrt(3.0)-1.0)
    -0.577350269189626,
    // -1.0 + 2.0 * C.x
    0.024390243902439);
    // 1.0 / 41.0

    // First corner (x0)
    vec2 i  = floor(v + dot(v, C.yy));
    vec2 x0 = v - i + dot(i, C.xx);

    // Other two corners (x1, x2)
    vec2 i1 = vec2(0.0);
    i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
    vec2 x1 = x0.xy + C.xx - i1;
    vec2 x2 = x0.xy + C.zz;

    // Do some permutations to avoid
    // truncation effects in permutation
    i = mod289(i);
    vec3 p = permute(permute( i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0 ));

    vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x1,x1), dot(x2,x2)), 0.0);

    m = m * m;
    m = m * m;

    // Gradients:
    //  41 pts uniformly over a line, mapped onto a diamond
    //  The ring size 17*17 = 289 is close to a multiple
    //      of 41 (41*7 = 287)
    vec3 x = 2.0 * fract(p * C.www) - 1.0;
    vec3 h = abs(x) - 0.5;
    vec3 ox = floor(x + 0.5);
    vec3 a0 = x - ox;

    // Normalise gradients implicitly by scaling m
    // Approximation of: m *= inversesqrt(a0*a0 + h*h);
    m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);

    // Compute final noise value at P
    vec3 g = vec3(0.0);

  g.x  = a0.x  * x0.x  + h.x  * x0.y;
  g.yz = a0.yz * vec2(x1.x, x2.x) + h.yz * vec2(x1.y, x2.y);
  return 130.0 * dot(m, g);
}

void main() {
  vec2 st = vUv * 20.0;
  gl_FragColor.rgb = vec3(0.5 * noise(st) + 0.5);
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

渲染效果如下:

Simplex Noise 可以实现出令人惊叹的效果:

# 网格噪声

网格噪声就是将噪声与网格结合使用的一种纹理生成技术。它是一种目前被广泛应用的程序化纹理技术,用来生成随机网格类的视觉效果,可以用来模拟物体表面的晶格、晶体生长、细胞、微生物等等有趣的效果。

下面就用这种技术来实现类似动态生物细胞 (opens new window)的例子。

首先,我们用网格技术将画布分为 10 * 10 的网格。然后,我们构建距离场。这个距离场是在每个网格中随机一个特征点,然后计算网格内到该点的距离,最后根据距离来着色。

const fragment = `
  #ifdef GL_ES
  precision highp float;
  #endif

  varying vec2 vUv;
  uniform float uTime;

  vec2 random2(vec2 st){
    st = vec2(dot(st,vec2(127.1,311.7)), 
      dot(st,vec2(269.5,183.3)));
    return fract(sin(st) * 43758.5453123);
  }

  void main() {
    vec2 st = vUv * 10.0;

    float d = 1.0;
    vec2 i_st = floor(st);
    vec2 f_st = fract(st);

    vec2 p = random2(i_st);
    d = distance(f_st, p);
    gl_FragColor.rgb = vec3(d);
    gl_FragColor.a = 1.0;
  }
`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

通过这一步实现的效果是,每个网格是独立的,并且界限分明。如果我们想让它们的边界过渡更圆滑,就不仅要计算特征点到当前网格的距离,还要计算它到周围相邻的 8 个网格的距离,然后取最小值。这个可以通过 for 循环来实现:

void main() {
  vec2 st = vUv * 10.0;
  float d = 1.0;
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);

  for (int i = -1; i <= 1; i++) {
    for (int j = -1; j <= 1; j++) {
      vec2 neighbor = vec2(float(i), float(j));
      vec2 p = random2(i_st + neighbor);
      d = min(d, distance(f_st, neighbor + p));
    }
  }

  gl_FragColor.rgb = vec3(d);
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这里需要注意的是,GLSL 语言的 for 循环限制比较多。其中,检查循环是否继续的次数必须是常量,不能是变量。所以 GLSL 中没有动态循环,而且迭代的次数必须是确定的。这里我们要检查 9 个网格,所以就用了两重循环来实现。

最后,再加上 uTime,让网格动起来,同时把特征点也给显示出来。就得到类似动态生物细胞 (opens new window)的效果了。

void main() {
  vec2 st = vUv * 10.0;

  float d = 1.0;
  vec2 i_st = floor(st);
  vec2 f_st = fract(st);

  for (int i = -1; i <= 1; i++) {
    for (int j = -1; j <= 1; j++) {
      vec2 neighbor = vec2(float(i), float(j));
      vec2 p = random2(i_st + neighbor);
      p = 0.5 + 0.5 * sin(uTime + 6.2831 * p);
      d = min(d, distance(f_st, neighbor + p));
    }
  }

  gl_FragColor.rgb = vec3(d) + step(d, 0.03);
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 噪声总结

以上关于噪声的内容实际上是一种复杂的程序化纹理生成技术所谓程序化纹理生成技术,就是用程序来生成物体表面的图案。我们在这些图案中引入类似于自然界中的随机性,就可以模拟出自然的、丰富多采的以及包含真实细节的纹理图案。

这其中最有代表性的就是噪声了,噪声就是随机性与连续性结合而成的。噪声是自然界中普遍存在的自然规律。模拟噪声的基本思路是对离散的随机数进行平滑处理,对随机数进行平滑处理有不同的数学技巧,所以有插值噪声、梯度噪声、Simplex Noise 等等不同的噪声算法。它们各有特点,我们可以根据不同的情况来选择怎么使用。

# 更多噪声应用的例子:Shadertoy

  • Shadertoy (opens new window) 是一个非常优秀的创作和分享着色器效果的平台,我们可以在上面学习到很多优秀的案例。

# 如何使用后期处理通道增强图像效果

前面学习了利用向量和矩阵公式,来处理像素和生成纹理的技巧,但是这些技巧都有一定的局限性:每个像素是彼此独立的,不能共享信息

这是因为 GPU 是并行渲染的,所以在着色器的执行中,每个像素的着色都是同时进行的。这样一来,我们就不能获得某一个像素坐标周围坐标点的颜色信息,也不能获得要渲染图像的全局信息。

这种局限性会导致的问题就是,如果我们要实现与周围像素点联动的效果,比如给生成的纹理添加平滑效果滤镜,就不能直接通过着色器的运算来实现了。

因此,在 WebGL 中,像这样不能直接通过着色器运算来实现的效果,我们需要使用其他的办法来实现,其中一种办法就是使用后期处理通道

# 什么是后期处理通道

所谓后期处理通道,是指将渲染出来的图像作为纹理输入给新着色器处理,是一种二次加工的手段。

这么一来,虽然我们不能从当前渲染中获取周围的像素信息,却可以从纹理中获取任意 uv 坐标下的像素信息,也就相当于可以获取任意位置的像素信息了。

使用后期处理通道的一般过程是,我们先正常地将数据送入缓冲区,然后执行 WebGLProgram。只不过,在执行了 WebGLProgram 之后,我们要将输出的结果再作为纹理,送入另一个 WebGLProgram 进行处理,这个过程可以进行一次,也可以循环多次。最后,经过两次 WebGLProgram 处理之后,我们再输出结果

# 用后期处理通道实现 Blur 滤镜

前面我们已经在 Canvas2D 中实现了 Blur 滤镜(高斯模糊的平滑效果滤镜),但 Canvas2D 实现滤镜的性能不佳,尤其是在图片较大,需要大量计算的时候。

而在 WebGL 中,我们可以通过后期处理来实现高性能的 Blur 滤镜。下面就以给随机三角形图案加 Blur 滤镜 (opens new window)为例,来说说具体的操作。

首先,我们实现一个绘制随机三角形图案的着色器。

#ifdef GL_ES
precision highp float;
#endif

float line_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  return cross(p, normalize(ab)).z;
}

float seg_distance(in vec2 st, in vec2 a, in vec2 b) {
  vec3 ab = vec3(b - a, 0);
  vec3 p = vec3(st - a, 0);
  float l = length(ab);
  float d = abs(cross(p, normalize(ab)).z);
  float proj = dot(p, ab) / l;
  if(proj >= 0.0 && proj <= l) return d;
  return min(distance(st, a), distance(st, b));
}

float triangle_distance(in vec2 st, in vec2 a, in vec2 b, in vec2 c) {
  float d1 = line_distance(st, a, b);
  float d2 = line_distance(st, b, c);
  float d3 = line_distance(st, c, a);

  if(d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 || d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0) {
    return -min(abs(d1), min(abs(d2), abs(d3))); // 内部距离为负
  }
  
  return min(seg_distance(st, a, b), min(seg_distance(st, b, c), seg_distance(st, c, a))); // 外部为正
}

float random (vec2 st) {
  return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}

vec3 hsb2rgb(vec3 c){
  vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}

varying vec2 vUv;

void main() {
  vec2 st = vUv;
  st *= 10.0;
  vec2 i_st = floor(st);
  vec2 f_st = 2.0 * fract(st) - vec2(1);
  float r = random(i_st);
  float sign = 2.0 * step(0.5, r) - 1.0;
  
  float d = triangle_distance(f_st, vec2(-1), vec2(1), sign * vec2(1, -1));
  gl_FragColor.rgb = (smoothstep(-0.85, -0.8, d) - smoothstep(0.0, 0.05, d)) * hsb2rgb(vec3(r + 1.2, 0.5, r));
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

这个着色器绘制出的效果如下图:

接着就要使用后期处理通道对它进行高斯模糊。

首先,我们需要准备另一个着色器:blurFragment。通过它,我们能将第一次渲染后生成的纹理 tMap 内容给显示出来。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform sampler2D tMap;

void main() {
  vec4 color = texture2D(tMap, vUv);
  gl_FragColor.rgb = color.rgb;
  gl_FragColor.a = color.a;
}
1
2
3
4
5
6
7
8
9
10
11
12

然后,我们要修改 JavaScript 代码,把渲染分为两次。第一次渲染时,我们启用 program 程序,但不直接把图形输出到画布上,而是输出到一个帧缓冲对象(Frame Buffer Object)上。第二次渲染时,我们再启用 blurProgram 程序,将第一次渲染完成的纹理(fbo.texture)作为 blurFragment 的 tMap 变量,这次的输出绘制到画布上。

// ...

renderer.useProgram(program);

renderer.setMeshData([{
  positions: [
    [-1, -1],
    [-1, 1],
    [1, 1],
    [1, -1],
  ],
  attributes: {
    uv: [
      [0, 0],
      [0, 1],
      [1, 1],
      [1, 0],
    ],
  },
  cells: [[0, 1, 2], [2, 0, 3]],
}]);

const fbo = renderer.createFBO();
renderer.bindFBO(fbo);
renderer.render();
renderer.bindFBO(null);

const blurProgram = renderer.compileSync(blurFragment, vertex);
renderer.useProgram(blurProgram);
renderer.setMeshData(program.meshData);
renderer.uniforms.tMap = fbo.texture;
renderer.render();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

其中,renderer.createFBO 是创建帧缓冲对象,bindFBO 是绑定帧缓冲对象。为了方便调用,在这里通过 gl-renderer 做了一层简单的封装。

经过两次渲染之后,我们运行程序输出的结果和之前输出的并不会有什么区别。因为第二次渲染只不过是将第一次渲染到帧缓冲的结果原封不动地输出到画布上了。

接下来,再修改 blurFragment 的代码,在其中添加高斯模糊的代码。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform sampler2D tMap;
uniform int axis;

void main() {
  vec4 color = texture2D(tMap, vUv);

  // 高斯矩阵的权重值
  float weight[5];
  weight[0] = 0.227027;
  weight[1] = 0.1945946;
  weight[2] = 0.1216216;
  weight[3] = 0.054054;
  weight[4] = 0.016216;

  // 每一个相邻像素的坐标间隔,这里的 512 可以用实际的 Canvas 像素宽代替
  float tex_offset = 1.0 / 512.0;

  vec3 result = color.rgb;
  result *= weight[0];
  for(int i = 1; i < 5; ++i) {
    float f = float(i);
    if(axis == 0) { // x 轴的高斯模糊
      result += texture2D(tMap, vUv + vec2(tex_offset * f, 0.0)).rgb * weight[i];
      result += texture2D(tMap, vUv - vec2(tex_offset * f, 0.0)).rgb * weight[i];
    } else { // y 轴的高斯模糊
      result += texture2D(tMap, vUv + vec2(0.0, tex_offset * f)).rgb * weight[i];
      result += texture2D(tMap, vUv - vec2(0.0, tex_offset * f)).rgb * weight[i];
    }
  }

  gl_FragColor.rgb = result.rgb;
  gl_FragColor.a = color.a;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

因为高斯模糊有两个方向,x 和 y 方向,所以我们至少要执行两次渲染,一次对 x 轴,另一次对 y 轴。如果想要达到更好的效果,我们还可以执行多次渲染。

这里就以分别对 x 轴和 y 轴执行 2 次渲染为例,修改后的 JavaScript 代码如下:

// 创建两个 FBO 对象交替使用
const fbo1 = renderer.createFBO();
const fbo2 = renderer.createFBO();

// 第一次,渲染原始图形
renderer.bindFBO(fbo1);
renderer.render();

// 第二次,对 x 轴高斯模糊
renderer.useProgram(blurProgram);
renderer.setMeshData(program.meshData);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 0;
renderer.render();

// 第三次,对 y 轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo1);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.render();

// 第四次,对 x 轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(fbo2);
renderer.uniforms.tMap = fbo1.texture;
renderer.uniforms.axis = 0;
renderer.render();

// 第五次,对 y 轴高斯模糊
renderer.useProgram(blurProgram);
renderer.bindFBO(null);
renderer.uniforms.tMap = fbo2.texture;
renderer.uniforms.axis = 1;
renderer.render();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

在这段代码中,我们创建了两个 FBO 对象,然后将它们交替使用。我们一共进行 5 次绘制,先对原始图片执行 1 次渲染,再进行 4 次后期处理。

这里有个小技巧,本来,在执行的这 5 次绘制中,前四次都是输出到帧缓冲对象,所以我们至少需要 4 个 FBO 对象。但是,由于我们可以交替使用 FBO 对象,也就是可以把用过的对象重复使用。因此,无论需要绘制多少次,我们都只要创建两个对象就可以,也就节约了内存。

最终,我们就能通过后期处理通道实现 Blur 滤镜,给三角形图案加上模糊的效果了。渲染结果如下:

# 用后期处理通道实现辉光效果

辉光效果 (opens new window)的实现可以在 Blur 滤镜的基础上进行修改。

首先,我们给 blurFragment 加了一个关于亮度的滤镜,将颜色亮度大于 filter 值的三角形过滤出来添加高斯模糊。

uniform float filter;
      
void main() {
  vec4 color = texture2D(tMap, vUv);
  float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
  brightness = step(filter, brightness);

  // 高斯矩阵的权重值
  float weight[5];
  weight[0] = 0.227027;
  weight[1] = 0.1945946;
  weight[2] = 0.1216216;
  weight[3] = 0.054054;
  weight[4] = 0.016216;

  // 每一个相邻像素的坐标间隔,这里的 512 可以用实际的 Canvas 像素宽代替
  float tex_offset = 1.0 / 512.0;

  vec3 result = color.rgb;
  result *= weight[0];
  for(int i = 1; i < 5; ++i) {
    float f = float(i);
    if(axis == 0) { // x 轴的高斯模糊
      result += texture2D(tMap, vUv + vec2(tex_offset * f, 0.0)).rgb * weight[i];
      result += texture2D(tMap, vUv - vec2(tex_offset * f, 0.0)).rgb * weight[i];
    } else { // y 轴的高斯模糊
      result += texture2D(tMap, vUv + vec2(0.0, tex_offset * f)).rgb * weight[i];
      result += texture2D(tMap, vUv - vec2(0.0, tex_offset * f)).rgb * weight[i];
    }
  }

  gl_FragColor.rgb = brightness * result.rgb;
  gl_FragColor.a = color.a;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

然后,我们再增加一个 bloomFragment 着色器,用来做最后的效果混合。这里会用到一个叫做 Tone Mapping(色调映射) (opens new window)的方法。这个方法就比较复杂,它的作用就是可以将对比度过大的图像色调映射到合理的范围内。

bloomFragment 着色器的代码如下:

#ifdef GL_ES
  precision highp float;
#endif

uniform sampler2D tMap;
uniform sampler2D tSource;

varying vec2 vUv;

void main() {
  vec3 color = texture2D(tSource, vUv).rgb;
  vec3 bloomColor = texture2D(tMap, vUv).rgb;
  color += bloomColor;
  // tone mapping
  float exposure = 2.0;
  float gamma = 1.3;
  vec3 result = vec3(1.0) - exp(-color * exposure);
  // also gamma correct while we're at it
  if(length(bloomColor) > 0.0) {
    result = pow(result, vec3(1.0 / gamma));
  }
  gl_FragColor.rgb = result;
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

最后,我们修改 JavaScript 渲染的逻辑,添加新的后期处理规则。这里,我们要使用三个 FBO 对象,因为第一个 FBO 对象在渲染原始图形之后,还要在混合效果时使用,后两个对象是用来交替使用完成高斯模糊的。最后,我们再将原始图形和高斯模糊的结果进行效果混合就可以了。最终的效果看这里 (opens new window)

这样,我们就实现了最终的局部辉光效果。实现它的关键,就是在高斯模糊原理的基础上,将局部高斯模糊的图像与原始图像叠加

# 用后期处理通道实现烟雾效果

首先,我们创建一个简单的 shader,也就是使用距离场在画布上画一个圆。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;

void main() {
  vec2 st = vUv - vec2(0.5);
  float d = length(st);
  gl_FragColor.rgb = vec3(1.0 - smoothstep(0.05, 0.055, d));
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12

接着,我们修改一下 shader 代码,增加 uTime、tMap 这两个变量。其中,uTime 用来控制图像随时间变化,而 tMap 是我们用来做后期处理的变量。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform sampler2D tMap;
uniform float uTime;

void main() {
  vec3 smoke = vec3(0);
  if(uTime <= 0.0) {
    vec2 st = vUv - vec2(0.5);
    float d = length(st);
    smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
  }
  vec3 diffuse = texture2D(tMap, vUv).rgb;
  gl_FragColor.rgb = diffuse + smoke;
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

然后,我们依然创建两个 FBO,用它们交替进行绘制。最后,我们把绘制的内容输出到画布上。这里,我使用了一个 if 语句,根据绘制过程判断初始绘制还是后续的叠加过程,就能把着色器合并成一个。这样一来,不管是输出到画布还是 FBO,我们使用同一个 program 就可以了。

const fbo = {
  readFBO: renderer.createFBO(),
  writeFBO: renderer.createFBO(),
  get texture() {
    return this.readFBO.texture;
  },
  swap() {
    const tmp = this.writeFBO;
    this.writeFBO = this.readFBO;
    this.readFBO = tmp;
  },
};

function update(t) {
  // 输出到画布
  renderer.bindFBO(null);
  renderer.uniforms.uTime = t / 1000;
  renderer.uniforms.tMap = fbo.texture;
  renderer.render();
  // 同时输出到 FBO
  renderer.bindFBO(fbo.writeFBO);
  renderer.uniforms.tMap = fbo.texture;
  // 交换读写缓冲以便下一次写入
  fbo.swap();
  renderer.render();
  requestAnimationFrame(update);
}
update(0);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

执行以上代码会发现,输出的画面并没有什么变化。这是因为我们第一次渲染时,也就是当 uTime 为 0 的时候,我们直接画了一个圆。而当我们从上一次绘制的纹理中获取信息,重新渲染时,因为每次获取的纹理图案都是不变的,所以现在的画面依然是静止的圆。

如果我们想让这个图动起来,比如说让它向上升,那么我们只要在每次绘制的时候,改变一下采样的 y 坐标,就是每次从 tMap 取样时取当前纹理坐标稍微下方一点的像素点就可以了。

void main() {
  vec3 smoke = vec3(0);
  if (uTime <= 0.0) {
    vec2 st = vUv - vec2(0.5);
    float d = length(st);
    smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
  }
  vec2 st = vUv;
  st.y -= 0.001;
  vec3 diffuse = texture2D(tMap, st).rgb;
  gl_FragColor.rgb = diffuse + smoke;
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13

不过,由于纹理采样精度的问题,我们得到的上升圆还会有一个扩散的效果。不过这没有关系,它不影响我们接下来要实现的烟雾效果。

接下来,我们需要构建一个烟雾的扩散模型,也就是以某个像素位置以及周边像素的纹理颜色来计算新的颜色值。下面就以一个 5 * 5 的画布为例来详细说说。假设,这个画布只有中心五个位置的颜色是纯白(1.0),周围都是黑色,如下图所示。

在这个扩散模型中,每个格子到下一时刻的颜色变化量,等于它周围四个格子的颜色值之和减去它自身颜色值的 4 倍,乘以扩散系数。

假设扩散系数是常量 0.1,那么第一轮每一格的颜色值都在表格上标出来了,如下图所示。

上面三种颜色的格子的计算过程如下:

  • 首先是中间红色的那个格子。因为它四周的格子颜色都是 1.0,所以它的颜色变化量是:0.1 * ((1.0 + 1.0 + 1.0 + 1.0) - 4 * 1.0) = 0,那么下一帧的颜色值还是 1.0 不变。

  • 其次,红格子周围的四个蓝色格子。它们下一帧的颜色变化量为:0.1 * ((1.0 + 0 + 0 + 0)- 4 * 1.0) = -0.3,那么它们下一帧的颜色值都要减去 0.3 就是 0.7。

  • 最后,在计算绿色格子下一帧的颜色值时,要分为两种情况。

    • 第一种,当要计算的绿色格子和两个蓝色格子相邻的时候,颜色变化量为:0.1 * ((1.0 + 1.0 + 0 + 0) - 4 * 0) = 0.2,所以绿格子下一帧的颜色值变为 0.2。

    • 第二种,当这个绿色格子只和一个蓝色格子相邻的时候,颜色变化量为 0.1,那么绿格子下一帧的颜色值就变为 0.1。

就这样,我们把每一帧颜色按照这个规则不断迭代下去,就能得到一个烟雾扩散效果了。

下一步就是把它实现到 Shader 中,不过,在 Fragment Shader 中添加扩散模型的时候,为了让这个烟雾效果,能上升得更明显,这里稍稍修改了一下扩散公式的权重,让它向上的幅度比较大。

#ifdef GL_ES
precision highp float;
#endif

varying vec2 vUv;
uniform sampler2D tMap;
uniform float uTime;

void main() {
  vec3 smoke = vec3(0);
  if(uTime <= 0.0) {
    vec2 st = vUv - vec2(0.5);
    float d = length(st);
    smoke = vec3(step(d, 0.05));
    // smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
  }

  vec2 st = vUv;

  float offset = 1.0 / 256.0;
  vec3 diffuse = texture2D(tMap, st).rgb;

  vec4 left = texture2D(tMap, st + vec2(-offset, 0.0));
  vec4 right = texture2D(tMap, st + vec2(offset, 0.0));
  vec4 up = texture2D(tMap, st + vec2(0.0, -offset));
  vec4 down = texture2D(tMap, st + vec2(0.0, offset));

  float diff = 8.0 * 0.016 * (
    left.r + 
    right.r + 
    down.r + 
    2.0 * up.r - 
    5.0 * diffuse.r
  );

  gl_FragColor.rgb = (diffuse + diff) + smoke;
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

为了达到更真实的烟雾效果,我们还可以在扩散函数上增加一些噪声。

void main() {
  vec3 smoke = vec3(0);
  if(uTime <= 0.0) {
    vec2 st = vUv - vec2(0.5);
    float d = length(st);
    smoke = vec3(step(d, 0.05));
    // smoke = vec3(1.0 - smoothstep(0.05, 0.055, d));
  }

  vec2 st = vUv;

  float offset = 1.0 / 256.0;
  vec3 diffuse = texture2D(tMap, st).rgb;

  vec4 left = texture2D(tMap, st + vec2(-offset, 0.0));
  vec4 right = texture2D(tMap, st + vec2(offset, 0.0));
  vec4 up = texture2D(tMap, st + vec2(0.0, -offset));
  vec4 down = texture2D(tMap, st + vec2(0.0, offset));

  float rand = noise(st + 5.0 * uTime);
  float diff = 8.0 * 0.016 * (
    (1.0 + rand) * left.r + 
    (1.0 - rand) * right.r + 
    down.r + 
    2.0 * up.r - 
    5.0 * diffuse.r
  );

  gl_FragColor.rgb = (diffuse + diff) + smoke;
  gl_FragColor.a = 1.0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

这样,最终实现的效果 (opens new window)看起来就会更真实一些。

How to Write a Smoke Shader (opens new window) 这篇文章详细讲了烟雾生成的方法,可以仔细研究学习下。

# 用后期处理通道实现探照灯效果

方法暂未找到,这里有一种不用后期处理通道实现的探照灯效果 (opens new window)

# 帧缓冲对象的创建和使用方法

上次更新时间: 2022年03月04日 21:09:41