You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

23 KiB

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

你好,我是月影。

图案生成是可视化中非常重要的基础。有多重要呢?我们知道,可视化中的几何图形是用来表达数据的,那图案就是用来修饰这些几何图形,强化视觉效果的,所以图案一般是指几何图形上的花纹。这些花纹有的简单,有的复杂,有的规律明显,有的看上去比较随机。也正是因为图案可以如此的不同,它们才能更好地增强视觉效果。

这一节课,我们就来聊一聊图案生成的基本原理和方法论。不过,因为可视化中的图案非常多,所以今天我们主要来讲三种最常用的,分别是重复图案、分形图案和随机图案。

首先,我们来看重复图案。

如何绘制大批量重复图案

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

那像网格这样经典的重复图案我们应该怎样绘制它呢这些网格看起来像是由一条一条线段组成的是不是利用绘制线段的方式比如我们之前学过的Canvas2D的绘图指令来绘制就可以了如果你是这么想的就把问题想得太简单了。

举个例子如果我们将网格绘制在Canvas2D画布上那网格的线条就会很多这也就意味着我们要用大量的绘图指令来绘制。这个时候一旦Canvas2D的画面改变了我们就需要重绘全部的网格这会大大消耗系统的性能。而且如果将大量的时间都浪费在绘制这种重复图案上那我们实现的代码性能可能就会很差。

那我们该怎么办呢你可能会想到准备两个Canvas2D画布一个用来绘制网格另一个用来绘制其他会变化的图形。能想到这个办法还是不错的说明你动了脑筋它确实解决了图案重绘的问题。不过我们第一次绘图的开销仍然存在。因此我们的解决思路不能局限在使用Canvas2D的绘图指令上。

1. 使用background-image来绘制重复图案

我们有更巧妙的办法来“绘制”这种网格图案那就是使用CSS的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;
}

以防你对CSS的linear-gradient属性还不太熟悉我这里简单解释一下它。CSS的linear-gradient属性可以定义线性渐变在这个例子里to right 表示颜色过渡是从左到右的其中0%到90%的区域是透明的90%到100%的区域是#ccc颜色。另外在linear-gradient中定义颜色过渡的时候如果后一个过渡颜色的区域值和前面相同我们可以把它简单写为0。

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

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

总结来说这种利用了CSS属性设置重复网格背景的技巧在一般情况下能够满足我们的需要但也会有一些限制。首先因为它设置的是Canvas元素的背景所以它和直接绘制在画布上的其他图形就处于不同的层我们也就没法将它覆盖在这些图形上了。其次当我们用坐标变换来缩放或移动元素时作为元素背景的网格是不会随着缩放或移动而改变的。

2. 使用Shader来绘制重复图案

那如果是用WebGL来渲染的话我们还有更简单的做法就是利用GPU并行计算的特点使用着色器来绘制背景网格这样的重复图案。

这里,我直接给出了顶点着色器和片元着色器中的代码,你可以看看。

//顶点着色器:

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);


//片元着色器:


#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;
}


那这两段Shader代码的具体行为是什么呢你可以先自己想一想这里我先卖个关子一会儿再详细解释我们先来看看WebGL绘制重复图案的过程。

我们知道直接用WebGL来绘图比较繁琐所以从这一节课开始我们不采用原生的底层WebGL绘图了而是采用一个基础库gl-renderer。gl-renderer在WebGL底层的基础上进行了一些简单的封装以便于我们将重点放在提供几何数据、设置变量和编写Shader上不用因为创建buffer等细节而分心。

gl-renderer的使用方法十分简单基本上和第4节课WebGL三角形的过程一致一共分为五步唯一的区别是gl-renderer对每一步的代码进行了封装。我把这五步都列出来了我们一起来看看。

步骤一和步骤二分别是创建Renderer对象和创建并启用WebGL程序,过程非常简单,你直接看我给出的代码就可以理解了。

//第一步:
const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);

//第二步:
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);

步骤三和步骤四是最核心的两个步骤,我来重点说说。

**步骤三是设置uniform变量。**这里我们设置了一个rows变量表示每一行显示多少个网格。然后我们会在片元着色器中使用它。

renderer.uniforms.rows = 64;

步骤四是将顶点数据送入缓冲区。

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]],
}]);

在上面的代码中我们一共设置了三个数据。首先我们设置了positions也就是顶点。这里我们一共设置了四个顶点这四个顶点坐标正好覆盖了整个Canvas画布。接着是uv也就是纹理坐标。它和纹理设置有关不过你先不用理解什么是纹理设置只要知道这个坐标系的左下角为0,0右上角为1,1就可以了。

第三个是cells顶点索引。我们知道WebGL只能渲染经过三角剖分之后的多边形。那利用cells: [(0, 1, 2), (2, 0, 3)],我们就能将这个矩形画布剖分成两个三角形,这两个三角形的顶点下标分别是(0, 1, 2)和(2, 0, 3)。

最后我们将顶点送入缓冲区后执行renderer.render()渲染,网格就被渲染出来了。

接下来,我们重点看一下片元着色器中的代码,来理解一下渲染过程。

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;
}

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

所以这里我们用vUv也就是由顶点着色器传来的uv属性纹理坐标乘上rows值然后用fract取小数部分就能得到st了。

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

因此d1和d2分别有2种取值情况。

最后我们要根据d1 * d2的值决定背景网格使用哪个颜色来绘制。要实现这个目的我们就要使用到第三个函数mix。mix是线性插值函数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),也就是灰色。

最后因为rows决定网格重复的次数所以最终的效果和rows的取值有关。为了让你有更直观的感受我把row分别取1、4、16、32、64时的效果都绘制出来了你可以看看。

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

如何绘制分形图案

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

实际上分形在实践中偏向于视觉和UI设计。虽然它在实际的可视化项目中不太常用但总能够起到画龙点睛的作用。所以了解分形在视觉呈现中的实现技巧还是很有必要的。下面我们就来详细讲讲分形是怎么实现的。

首先我们来认识一下分形公式Mandelbrot Set也叫曼德勃罗特集。它是由美国数学家曼徳勃罗特教授发现的迭代公式构成的分形集合。这个公式中Zn和Zn+1是复数C是一个实数常量。

这个迭代公式使用起来非常简单,只要我们给定一个初始值,它就能产生许多有趣的图案。接下来,我们就一起来看一个有趣的例子。

首先我们实现一个片元着色器,代码如下:

#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;
}

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

我们把(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();

这个图案本身似乎没有什么特别的效果我们可以修改一下Shader中的代码改变渲染颜色的规则根据迭代次数和迭代深度的比值来渲染不同的颜色然后将它局部放大就能得到非常有趣的图案了。

如何给图案增加随机效果

那分形图案为什么这么吸引人呢?如果你多看几个,就会发现,它们的无限细节里同时拥有重复和随机这两个规律。那对于其他非分形的图案,如果也想让它变得吸引人,我们其实可以给它们增加随机效果

不知道,你还记得我们开篇词中的那个抽奖程序吗?实际上它就是一个随机效果的应用。

要想实现类似这样的随机效果在Shader中我们可以使用伪随机函数。下面我以一个常用的伪随机函数为例来讲讲随机效果是怎么生成的。代码如下

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

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

我们可以测试一下这个伪随机函数,代码如下:

#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;
}

它的执行结果是一片噪点,效果如下图所示。

这些噪点显然不能满足我们想要的随机效果因为它们只有一个像素而且太小了。所以下一步我们可以用floor取整函数来生成随机的色块。

  #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;
  }

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

此外我们还可以结合随机和动态效果。具体的方法就是传入一个代表时间的uTime变量实际代码和最终效果如下

#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;
}

除此之外我们用Shader来实现网格类的效果也特别方便。比如下面我们就在Shader中用smoothstep函数生成可以随机旋转方向的线段从而生成一个迷宫。

#ifdef GL_ES
precision mediump float;
#endif

#define PI 3.14159265358979323846

varying vec2 vUv;
uniform vec2 u_resolution;
uniform int rows;

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

vec2 truchetPattern(in vec2 _st, in float _index){
    _index = fract(((_index-0.5)*2.0));
    if (_index > 0.75) {
        _st = vec2(1.0) - _st;
    } else if (_index > 0.5) {
        _st = vec2(1.0-_st.x,_st.y);
    } else if (_index > 0.25) {
        _st = 1.0-vec2(1.0-_st.x,_st.y);
    }
    return _st;
}

void main() {
    vec2 st = vUv * float(rows);
    vec2 ipos = floor(st);  // integer
    vec2 fpos = fract(st);  // fraction

    vec2 tile = truchetPattern(fpos, random( ipos ));
    float color = 0.0;

    color = smoothstep(tile.x-0.3,tile.x,tile.y)-
            smoothstep(tile.x,tile.x+0.3,tile.y);

    gl_FragColor = vec4(vec3(color),1.0);
}

要点总结

今天,我们讲了可视化中三种常用图案的生成原理。

第一种批量重复图案。一般来说在绘制批量重复图案的时候我们可以采用2种方案。首先是使用CSS的background-image属性利用backgroud-repeat快速重复绘制。其次我们可以使用片元着色器利用GPU的并行渲染的特点来绘制。

第二种,分形图案。绘制分形图案有一个可以直接的公式,曼德勃罗特集。我们可以使用它来绘制分形图案。

第三种是在重复图案上增加随机性,我们可以在片元着色器中使用伪随机函数,来给重复图案实现随机效果。

虽然我们说几何图形是用来承载数据信息,图案是来强化视觉效果的,但实际上,它们也并没有绝对的界限,有时候我们也可以将图案与数据信息一起管理。比如说,在上面那个动态效果的例子中,我们可以调整动态参数,让图形的运动速度或者黑白块的分布和数据量或者信息内容联系起来。这会大大强化可视化的视觉效果,从而加深用户对信息的理解。

在这一节课我们讲了大量关于WebGL的片元着色器的知识。这是因为片元着色器是最适合生成和绘制这些图案的技术但这也并不意味着用其他图形系统比如SVG或者Canvas就没法很好地生成并绘制这些图案了。

实际上它们的基本原理是相同的所以用SVG或Canvas同样可以绘制这些图案。只不过因为SVG和Canvas渲染不能深入控制GPU底层所以就没法做到像WebGL这样并行高效地渲染这些图案。那如果在选择SVG和Canvas的可视化应用中需要绘制大量的这些图案就必然会导致性能瓶颈这也是为什么我们一定要了解和掌握WebGL技术只有这样我们才能真正掌握绘制极有视觉冲击力的复杂图案的能力。

最后,我还要啰嗦几句,如果你对片元着色器应用还不是很熟悉,对上面的代码还有疑问或者不是很理解,那也没有关系,你可以花一点时间,仔细研究一下GitHub 仓库的源代码。要记住,动手实践永远是我们最好的学习方式,没有之一。

另外,在接下来的课程里,我们还会大量使用片元着色器创建更多有趣、炫酷的视觉效果。所以,我也建议你去从头看看这份关于片元着色器的学习资料,The Book of Shaders,相信你会非常有收获。

小试牛刀

  1. 在前面的例子里我们实现了一个10*10的灰色块方阵这里我们使用的是灰度颜色你能够渲染出彩色方块吗你可以尝试将随机数映射成HSV坐标中的H然后绘制出不同的彩色方阵。

  2. 在实现抽奖程序的时候我们在Shader中使用的是伪随机函数random。那如果要实现真正的随机数我们该怎么做呢如果我们希望实现的迷宫图案在我们每次刷新网页的时候都不相同这个功能你可以实现吗你可以fork GitHub仓库的代码然后把伪随机迷宫图案修改成真正随机迷宫图案然后把你的代码和实际效果分享出来。

  3. 我们知道使用background-image的弊端是当我们用坐标变换来缩放或移动图形的时候作为元素背景的网格是不会随着缩放或移动而改变的。但使用Shader我们就能够避免这个问题了。

不过,我们在课程中没有给出缩放和移动图形的例子。你能够扩展我给出的例子,实现图案随着图形的缩放和移动变化的效果吗(这里,我再给你一个小提示,你可以使用顶点着色器和仿射变换矩阵来实现)?

欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!


源码

课程示例代码

推荐阅读

1
2