“数学是上帝描写自然的语言”伽利略说,爱因斯坦也说过,“纯数学让我们发现这些概念和联系这些概念之间的规律,这些概念和规律给了我们理解自然现象的钥匙“。数学一词在西方源于古希腊语,意思是通过学习获得知识的意思,因此早期的数学涵盖的范围比我们今天讲的数学要广得多,和人类的生活以更接近一些。但是和任何事物一样,数学也在不断的演化,这个发展过程使得数学变得高深起来,渐渐大家头脑里想到的数学只剩下数字、符号、公式和定理了。就这样数学看上去和生活、和美似乎渐行渐远了,但其实不然。

 

Jacob Joaquin-Sine Waves Selfie

 

感谢Jacob Joaquin大佬用P5.js写了SineWaves(见下图),用代码很好的展现了数学之美中的三角函数之美。这也很契合我的一个想法,“代码和文字、音乐、图片、画作都一样,都可以用来表现这个世界的智慧,这个世界的美”。

 

SineWaves

 

接下来首先我会为大家介绍一下三角函、随机数几种常见的分布、HSB颜色模式,然后讲解如何用Processing实现SinWaves,之后会介绍我在SinWaves的基础之上实现的SinWavesPlus(如下图)。在最后的拓展部分,我会大概介绍一下三角函数在生活中的应用:用余弦定理进行新闻分类。

 

SineWaves

 

希望大家能够喜欢!当然心急的同学可以先看看以上两个作品的在线展示和代码:

Jacob Joaquin的SinWaves:https://www.openprocessing.org/sketch/617085

我的SinWavesPlus:https://www.openprocessing.org/sketch/711256

我的github:https://github.com/pearmini/processing/tree/master/SineWaves

 

分析SinWaves发现,可以将作品看成由一系列的格子构成,而每一个格子里面有一个图案。其中,每一个图案处处相似,却又都不尽相同。相似是因为这些图案都是用正弦函数来绘制的,不同是因为函数设定的参数不同,而这些参数都是随机产生的。所以在准备阶段,我们先来大概了解一下正弦函数和常用的几种随机数的分布。

 

1.三角函数

“ Trigonometry is a sine of the times.”

相信大家对三角函数都不陌生:正弦、余弦、正切函数等,而今天我们的主角是:正弦函数。下面我们先来看一个最简单的正弦函数的图形: 

我们看到正弦函数输出的是介于-1和1之间的平滑曲线。这种类型的图形我们称之为“波动”。两点之间的周期性运动。吉他琴弦的运动,钟摆的运动,弹簧的运动等这些都属于波形运动。

但是这样的波动看上去难免单调,那接下来我们就看看如何来自定义一个与众不同的波动。

 

2.振幅

首先我们可以改变波动的高度,也就是函数的振幅:。大家可以比较一下当A分别等于0.5, 1, 2的时候的函数图像。

观察可得:当A=0.5时,正弦函数输出的是介于-0.5和0.5之间的平滑曲线;当A=0.5时,正弦函数输出的是介于-2和2之间的平滑曲线。也就是说A的绝对值越大,波动越高。

 

3.周期

然后我们可以改变波动的宽度,也就是函数的周期:。大家可以比较一下当T分别等于0.5, 1, 2的时候的函数图像。

观察可得:当T=0.5时,正弦函数输出的平滑曲线每过4π重复一次,周期为 4π;当T=2时,正弦函数输出的平滑曲线每过0.5π重复一次,周期为0.5π。也就是说$的绝对值越大,波动越窄。

 

4.初相

最后我们可以改变波动在水平方向的位置,这个可以通过改变初相实现:。大家可以比较一下当P分别等于0.5π,0,-0.5π的时候的函数图像。

观察可得:当P=0.5π时,正弦函数输出的平滑曲线相对于 向左平移了0.5π个单位,初相为0.5π;当P=-0.5π时,正弦函数输出的平滑曲线相对于 向右平移了-0.5π个单位,初相为-0.5π。也就是我们可以通过增加或者减小P来控制波动向左或者向右移动。

 

目前我们对正弦函数有了一个初步的了解,知道了决定波动图像形状和位置的主要参数。那么接下来我们就来看看设计其这些参数大小的基本思想。

 

1.随机数的分布

观察作品可以发现里面有100个波动图形,而显我们然不可能亲自设置每一个波动图案对应的正弦函数的参数。一种很好的办法就是采用随机的方式构建我们的波动系统,但是随机数的分布却大有学问。下面就让我们来看一下几种比较常用的随机数分布方式。

 

均匀分布

最常用的随机数分布方式应该是均匀分布了,在取值范围内获得每个值的概率都是相等的。

在Processing中我们可以用random函数来实现该效果。每一次调用random函数的时候,都会随机返回一个在指定范围里面的值。如果我们只是传入一个参数,会返回一个在0和这个参数之间的浮点数;如果我们传入两个参数,会返回一个在这两个参数之间的浮点数。

下面是返回10个均匀分布的随机数,它们在-5和5之间。

 

float min = -5, max = 5;

int cnt = 10;

float [] nums = new float[cnt];

for(int i = 0; i < cnt; i++){

  nums[i] = random(min, max);

}

 

在我们希望获得随机数没有任何规律和模式的时候可以选择这个方法。下面是个简单的小例子,每次均匀随机选择一个矩形增长高度。

 

正态分布

假如我们要描述真实世界中生物的高度,random函数显然就不恰当了。

 

在北京人行道中,随便挑一个路人,他的身高可能是随机的。但是这种随机性和random()函数产生的随机选并不一样。人们的身高并不是均匀分布的,拥有平均身高的人数总是比特别高或特别矮的人多的多,当然也存在特别高和特别矮的。

 

像这种随机数聚集在平均值附近的情况,我们称之为“正态分布”,也被称作“高斯分布”(以数学家高斯命名),在法国则被称为“拉普拉斯分布”。这两位数学家同时在19世纪初定义了这种分布情况。

 

把这种情况用图形画出来,我们就会得到类似下面的形状,称之为贝尔曲线:

 

这条曲线由一个数学函数产生,它描述了在给定平均值(通常用希腊字母μ表示)和标准差(用希腊字母σ表示)下的概率分布情况。

 

μ的值很好理解,以猴子为例,它们的身高为200~300,那么身高的平均值为250。那么,如果我说标准差为3或者15,你能理解两者之间的区别吗?上面的两张图已经给出了非常直观的答案,左图表示标准差比较小的情况——数据聚集在平均值附近,右图表示标准差比较大的情况——数据相对分散的落在平均值两边。

 

在Processing中,我们调用randomGaussian()从一系列均值为0,标准差为1满足正态分布的浮点数中返回一个浮点数。

 

下面这个例子,我们获得10个满足正态分布均值为320,标准差为60的浮点数。

 

float sd = 60, mean = 320;

int cnt = 10;

float [] nums = new float[cnt];

for(int i = 0; i < cnt; i++){

float x = randomGaussian();

nums[i] = sd * x + mean;

}

 

在我们希望获得的随机数大多数在某个值附近聚集,少部分出现极端情况下时可以用这种方法。在SineWavesPlus中我们会用到它。下面是一个简单的例子,按照正态分布分布随机选择一个位置画圆。

 

 

Perlin噪声

就像我们前面人的身高的例子,一定程度的随机性有利于我们对真实世界的行为模式进行编程建模。然而把随机性当作唯一的指导原则是不符合自然特征的。有个算法——“Perlin 噪声”(以其发明者“Ken Perlin”命名),把这个因素考虑进去了。

 

Perlin噪声比随机数展现出更符合“自然”的外观形象,因为它能生成符合自然排序(平滑)的伪随机数。下面左边的图片显示了经过“Perlin噪声”处理的效果,右图则展示了原始的随机数效果。

 

 

在Processing中已经内置了Perlin噪声算法的实现:noise()函数。noise()函数可以有1~3个参数,分别对应一维、二维、三维的计算,简单起见,我们这里就以一维为例。

 

获得一个范围在0到1之间noise随机数只用给noise函数传入任意的值t,然后将t值增加,再次传入noise函数,我们就可以获得下一个noise随机数。传入的t值不同,一般情况下获得的noise随机数不同。

 

t值递增的速度会影响噪声的平滑度。如果我们让t发生很大的跳跃,那么很多中间值会被跳过,得到的值也更随机。见下图。

 

下面是一个简单的例子,获得10个范围在0到10的noise随机数。

 

float t = 1;

float min = 0, max = 10;

int cnt = 10;

float [] nums = new float[cnt];

for(int i = 0; i < cnt; i++){

float x = noise(t);

t += 0.01;

nums[i] = map(x, 0, 1 , min, max);

}

 

当我们希望获得随机数具有某种特定的模式的时候我们可以选择这种方式。下面是使用了noise随机数模拟的自然界中随风摇曳的树和随波逐流的小舟。

 

自定义分布

如果以上Processing内置的随机数生成器都不能满足我们的需求呢?没问题,我们可以DIY。

 

固定概率

在概率是个常数的时候,定义一个随机数生成器很简单。比如我希望获得一个在0到100间的浮点数。其中,这个数在90到100之间的概率是百分之九十,在0到90之间的概率是百分之十,那么我们就可以如下定义:

 

float randomGenerator(){

float probability = 0.1;

if(random(1) < probability){

return random(90, 100);

}else{

return random(0, 90);

}

}

 

下面个这个例子获得以上10个自定义分布的随机数。

 

int cnt = 10;

float [] nums = new float[cnt];

for(int i = 0; i < cnt; i++){

nums[i] = randomGenerator();

}

 

非固定概率

但是,上面的随机数生成器只能表示一种固定的概率,我们想要的是一种通用的规则——数字越大,被选中的概率越大。例如:3.145要比3.144被选中的概率大一点的,虽然只是大了一点点。换言之,放到坐标轴中,如果用x轴表示随机数的大小,对应的y轴为选中的概率,那么我们可以得到它们之间的对应关系为 

一种方案是:每次产生两个随机数,第一个随机数就是普通的随机数,第二个随机数称作“资格随机数”,用来决定对第一个随机数的取舍。下面是具体的步骤(这里我们的随机数在0~1之间):

 

选择一个随机数: R1

计算R1被选中的资格概率P,P=R1

选择另一个随机数: R2

如果R2小于P,那么R1就是我们想要的随机数

如果R2大于P,回到第一步重新开始

 

下面这个函数(Monte Carlo 算法,以其赌场命名)实现了上面的算法,它返回0~1之间的随机数.

 

float montecarlo() {

  while (true) {

    float r1 = random(1);

    float probability = r1;

    float r2 = random(1);

 

    if (r2 < probability) {

      return r1;

    }

  }

}

 

如果我们希望上面的关系从 变成 ,那我们只用对以上的随机数生成器的资格概率P做以下的修改:

 

float probability = r1 * r1;

 

最后来看一下调用该自定义的随机生成器的方法。

 

int cnt = 10;

float [] nums = new float[cnt];

for(int i = 0; i < cnt; i++){

nums[i] = montecarlo();

}

 

HSB颜色模式

接下来我们介绍HSB颜色模式。

HSB又称HSV,表示一种颜色模式:在HSB模式中,H(hues)表示色相,S(saturation)表示饱和度,B(brightness)表示亮度。

 

HSB模式对应的媒介是人眼。HSB模式中S和B呈现的数值越高,饱和度明度越高,页面色彩强烈艳丽,对视觉刺激是迅速的,醒目的效果,但不益于长时间的观看。

 

色相(H,hue):在0~360°的标准色轮上,色相是按位置度量的。在通常的使用中,色相是由颜色名称标识的,比如红、绿或橙色。黑色和白色无色相。当hue取值从0增加到360的时候,对应的颜色从左变到右。

饱和度(S,saturation):表示色彩的纯度,为0时为灰色。白、黑和其他灰色色彩都没有饱和度的。在最大饱和度时,每一色相具有最纯的色光。取值范围0~100%。

亮度(B,brightness或V,value):是色彩的明亮度。为0时即为黑色。最大亮度是色彩最鲜明的状态。取值范围0~100%。

 

Processing默认的颜色模式是RGB,如果我们想将颜色模式修改成HSB,在setup函数中做如下设置即可。

 

void setup(){

colorMode(HSB);

}

 

讲了这么多数学公式,大家估计又出现数学真无聊的感觉了吧。不要着急,接下来我们就一起看看如何用Processing来实现两个作品。

 

1.SineWaves

 

首先我们来讲解SineWaves。原作者的代码是用P5.js以面向过程的思想写的,而我是用Processing以面向对象的思想写的。并且在保持效果基本不变的情况下,对原代码的某些部分进行了简化,使得代码更加地易懂。

 

第一步,建立一个栅格系统用来确定每一个波动图案的位置。

第二步,设置每一个波动图案的属性:振幅、周期、颜色等。最后,绘制每一个波动图案。

 

首先创建一个SineWaves.pde,在下面输入如下代码:

 

GridSystem gs;

void setup(){

  size(500, 500);

  //三个参数分别是格子的行数、列数以及边框的宽度。

  gs = new GridSystem(10, 10, 50); 

}

 

void draw(){

  background(16);

  gs.run();

}

class GridSystem{}

class Grid{}

 

创建栅格系统

我们希望栅格系统如下图,其中黑色部分是border,每一个蓝色的格子是我们每一个波动图形的画布。

 

那接下来我们就根据上面这个蓝图来定义GridSystem这个类。

 

class GridSystem{

  float gridSize, border;

  

  GridSystem(float row, float col, float _border){

    border = _border;

    

    //根据容器的宽度和边框宽度计算每一个格子的大小。

    gridSize = (width - border * 2) / col;

    

    //对每一个格子进行初始化。

    for(int i = 0 ; i < row; i++){

      for(int j = 0; j < col; j++){

      

//三个参数分别是:行号、列号、格子大小。

        grids.add(new Grid(i, j, gridSize));

      }

    }

  }

  

  void run(){

  //改变绘制的坐标原点。

    translate(border, border);

    for(Grid g : grids){

  //更新并绘制每一个格子内的波动图案。

      g.update();

      g.display();

    }

  }

}

 

绘制

按照顺序我们现在应该看如何设定每一个三角函数的参数,但是为了加速大家的理解,我们先来看一下这些参数是怎么用的。这样在后面看如何设定参数的时候,更能理解这样设定的原因。

 

第一步来看看Grid类的display方法,这个方法用来绘制对应的波动图形。

 

void display(){

   pushMatrix();

   

   //绘制的原点为每一个格子的中心。

   translate(xPos, yPos);

 

   //颜色

   noFill();

   stroke(c);

 

   //旋转角度

   rotate(theta);

 

   //自定义图形

   beginShape();

   

   //根据指定Asin(Tx+P)函数在区间[-waveWidth/2, waveWidth/2]生成一系列的点。

   for(float x = -waveWidth / 2; x < waveWidth / 2; x++){

     float y = sin(x * period + phase) * amplitude;

     vertex(x, y);

   }

   endShape();

   popMatrix();

 }

 

第二步来看看Grid类的update方法,这个方法能让我们的波动图像动起来。通过不断更新正弦函数的初相来实现。

 

void update(){

  //更新初相startPhase

  //如果phaseStep > 0,startPhase变大,图像向左移动

  //如果phaseStep < 0,startPhase变小,图像向右移动

  startPhase += phaseStep;

  phase = startPhase;

}

 

设置参数

知道了绘制一个波动图形需要什么参数,那么接下来我们就看如何声明和定义这些参数。需要说明TAU是Processing内置的一个常量:6.2831855,近似于2π。

 

class Grid{

  //声明一些控制波动图案展现的参数。

  float waveWidth, size;

  float xPos, yPos;

  float phase, startPhase, phaseStep;

  float theta, amplitude, period;

  color c;

  

  Grid(float rowIndex, float colIndex, float _size){

  //格子和波动的大小

    size = _size;

    waveWidth = size * 0.8;

 

//每个格子的中心位置

    xPos = colIndex * size + size / 2;

    yPos = rowIndex * size + size / 2;

 

//周期

    period = TAU * pow(2, random(5));

 

//振幅

amplitude = map(noise(rowIndex, colIndex), 0, 1, 0, 20);

 

//初相

    startPhase = 0;

 

//移动方向

    if(random(1) < 0.5){

      phaseStep = TAU / 128;

    }else{

      phaseStep = -TAU / 128;

    }

 

//旋转角度

    if(random(1) < 0.5){

      theta = HALF_PI;

    }else{

      theta = 0;

    }

    

    //颜色

    if(random(1) < 0.5){

      c = color(64, 255, 255);

    }else{

      c = color(248, 64, 248);

    }

  }

 

  void update(){

  //...

  }

 

  void display(){

  //...

  }

}

 

目前我们的SineWaves就完成了,得到的效果如下图。

 

 

2.SineWavesPlus

 

接下来我们来重点讲解一下我在SineWaves基础上实现的SineWavesPlus。主要做了以下改变:点击鼠标改变渲染模式,改变了波动图形颜色、角度、振幅的设置方式,效果如图:

 

增加渲染模式

SineWavesPlus最大的改变就是增加了一种渲染模式,并且通过按任意键在两种模式下切换。首先我们在全局增加一个keyPressed函数如下,来响应键盘事件。

 

void keyPressed(){

  gs.changeRenderMode();

}

 

接下来定义GridSystem类的changeRenderMode方法:

 

void changeRenderMode(){

  //改变每一个波动图像的渲染模式

  for(Grid g : grids){

    g.changeRenderMode();

  }

}

 

然后我们来看看Grid类的changeRenderMode方法,马上我们将看到mode是一个int类型的变量。

 

void changeRenderMode(){

   //mode在0和1之间切换

   mode++;

   mode %= 2;

}

 

之后我们给Grid类添加如下的成员变量和常量。

 

final int STROKE_MODE = 0, FILL_MODE = 1;

float mode = STROKE_MODE;

 

最后我们把Grid类中display中设定颜色部分的代码改成如下的形式:

 

//颜色

if(mode == STROKE_MODE){

   noFill();

   stroke(c);

}else if(mode == FILL_MODE){

   noStroke();

   fill(c);

}

 

改变颜色

在SineWaves中的每一个波动图形只有两种颜色,但是SineWavesPlus的波动图形颜色非常丰富,并且随着每一个波动图形位置渐变的。下面我们就一起看看是如何实现的。

首先我们在setup函数中将颜色的模式从RGB变成HSB,并将每个通道的取值范围设置成0到100。

 

colorMode(HSB, 100);

 

然后我们把Grid类中display中设定颜色部分的代码改成如下的形式,将c作为每一个波动图案颜色的色相。

 

if(mode == STROKE_MODE){

   noFill();

   stroke(c, 100, 100);

}else if(mode == FILL_MODE){

   noStroke();

   fill(c, 100, 100);

}

 

之后再将Grid的成员c从Color变成float。

 

float c;

 

最后将c的设置修改如下:

 

c = map(noise(rowIndex / 10, colIndex / 10), 0, 1, 0, 100);

 

现在解释一下为什么这样设定:根据noise函数的特点我们知道想要返回的c色相相近,那么传入noise函数的参数也要接近。相邻的Grid要么rowIndex相差1,要么colIndex 相差1,同时缩小10倍后就只差0.1。根据最开始的讲解,每次传入参数的增加0.1,对应的颜色有差异并且变化相对平滑连续。

 

改变方向

在SineWaves中每个波动图案的方向只有两个:水平和竖直。我希望我的波动图案的方向是朝着四面八方的,但同时希望它们方向的总体趋势是竖着的。换句话说我希望它们的旋转角度聚集在0附近。

我们是用theta来表示每一个波动图案的角度的,于是将它的设定方式更改如下,利用之前提到的满足正态分布的随机数。

 

theta = randomGaussian() * 2;

 

改变振幅

现在我希望我的画面比较充实,也就是说想要更多振幅大的波动图形。换句话说就是振幅越大的波动图形出现的概率越大。

我们使用之前提到的montecarlo算法,给Grid类添加一个成员函数如下。

 

float montecarlo() {

  while (true) {

    float r1 = random(1);

    float probability = r1;

    float r2 = random(1);

    if (r2 < probability) {

      return r1;

    }

  }

}

 

最后,我们用自定义的这个随机数生成器替代之前的设置amplitude的方法。

 

amplitude = map(montecarlo(), 0, 1, 0, size / 2 * 0.7);

 

目前,我们的SineWavesPlus就完成啦!