第 0 章 引言

第 0 章 引言

“和自然在一起我永不孤独。”

——伍迪·艾伦

欢迎来到本书的引言部分。如果你已经很长时间没有用过Processing,在开始更难更复杂的话题之前,这篇引言能让你重新找回之前的编程思维。

在第1章里,我们会讨论向量的相关概念,了解为什么向量是运动模拟的基本组件。但在此之前,我们先探讨这样一个话题:如何在屏幕内简单地移动某个物体?让我们从一个最有名且最简单的运动模拟模型开始——随机游走。

0.1 随机游走

假设你站在一根平衡木中间,每10秒钟抛一枚硬币:如果硬币正面朝上,你向前走一步,背面朝上,则向后走一步。这就是随机游走——由一系列随机步骤构成的运动轨迹。然后,从平衡木转移到地面,你就可以做二维的随机游走了,不过每走一步需要抛两次硬币,而且需要按照以下规则移动:

第一次抛掷 第二次抛掷 结果
正面 正面 向前走一步
正面 反面 向右走一步
反面 正面 向左走一步
反面 反面 向后走一步

是的,这是一个很简单的算法,但随机游走可以对现实世界中的各种现象建模:从气体分子的运动到赌徒一整天的赌博活动不一而足。对我们来说,以随机游走作为本书的开头有三个目的。

1.借以回顾本书的中心编程思想——面向对象编程。我们要用面向对象方法来模拟物体在Processing窗口的运动,随机游走模型就是这个例子的模板。

2.随机游走模型引入了贯穿本书的两个关键问题:如何定义对象的行为规则,以及如何用Processing模拟这些行为规则。

3.在本书中,我们需要对随机性、概率和Perlin噪声有基本的了解,随机游走模型展示了其中的关键点,这在我们以后的学习中会很有用。

0.2 随机游走类

在构建Walker对象之前,我们先回顾面向对象编程(Object-oriented Programming,OOP)。注意,这只是一个很粗略的回顾,如果你之前没有接触过面向对象编程,可能需要更全面地学习它。我建议你现在停止阅读本书,先去Processing官方网站(http://processing.org/learning/objects/)上学习语言基础,学完之后再继续看本书。

Processing中的对象是拥有数据和功能的实体。我们要建立的Walker对象有以下特点:既维持了自身数据(在屏幕中的位置),又能够执行某些动作(比如绘制自身或者移动一步)。

是构建对象实例的模板,我们可以这么比喻它和对象的关系:类就是用来切割曲奇的模具,而对象就是曲奇。

首先,我们定义一个Walker类——Walker对象的模板。Walker对象只需要两部分数据——x坐标和y坐标。

class Walker{
    int x;
    int y;                       对象有数据
每个类都必须有一个构造函数。构造函数是特殊的函数,每次创建对象的时候都会被调用。你可以把它当成对象的`setup()`函数。 我们要在构造函数中设置`Walker`对象的初始位置(比如屏幕的正中间)。
Walker(){
    x = width/2;                  构造函数负责对象的初始化
    y = height/2;
}

除了数据,我们还可以在类中定义对象的功能函数。在这个例子中,一个Walker对象有两个函数。我们先实现一个用于显示自身的函数(画一个白色的点):

void display(){
    stroke(0);                              对象有函数
    point(x, y);
}

第二个函数用于控制对象的下一步移动。此时,事情是不是变得更有趣了?想一想之前如何在地面上随机移动。我们可以用同等大小的Processing窗口实现随机游走的模拟。这里有4个可能的移动动作:向右移动可以用递增x坐标(x++)模拟,向左时可以递减x坐标(x--),向前时可以递增y坐标(y++),向后时可以递减y坐标(y--)。还有一个问题:如何选择移动方向?先前我们用抛掷两枚硬币的方法确定移动方向,而在Processing中,如果要随机选择一个选项,可以用random()函数产生一个随机数。

void step(){
    int choice = int(random(4));         0、1、2或3

以上代码从0~3选出一个随机的浮点数,然后将它转化为整数,结果可能是0、1、2或3。从技术实现上讲,random(4)产生的最大浮点数不可能是4.0,只能是3.999 999 999......(小数点后面有无数个9)。浮点数转化为整数会抛弃所有小数位,因此我们得到的最大整数是3。下一步,我们根据这个随机数做出相应的移动(向左、向右、向上或向下)。

        if (choice == 0) {                移动由随机“选择”决定
            x++;
        } else if (choice == 1) {
            x--;
        } else if (choice == 2) {
            y++;
        } else { 
            y--;
        }
    }
}

既然我们已经完成了Walker类,下面要做的就是在Sketch的主体部分——setup()函数和draw()函数——中创建一个游走对象。本例假设只对单个游走对象建模,因此声明一个全局的Walker对象。

Walker w;                        Walker对象

然后,我们通过new操作符在setup()函数中调用对象的构造函数。

示例代码0-1 传统的随机游走

上面的标题代表本书的一个示例。对本书的每个示例,你都可以在GitHub中找到相应的代码(http://github.com/shiffman/The-Nature-of-Code-Examples)。

void setup(){
    size(640,360);
    w = new Walker();               创建一个Walker对象
    background(255);
}

最后,在每个draw()调用循环中,我们都让游走对象移动一步,并绘制一个点。

void draw(){
    w.step();                      调用Walker对象的方法
    w.display();
}

由于我们没有在每个draw()循环中都清除窗口的背景,而只是在setup()函数中绘制一次背景,因此可以看到Walker对象在整个Processing窗口中的运动轨迹。

图像说明文字

我们还可以对这个Walker对象做很多优化。比如,现在它只能朝4个方向移动——上下左右,但是屏幕上的每个像素点分别有8个相邻的像素点,对象有可能移动到任何一个相邻的像素点上,除此之外还有第9种可能:呆在原地不动。

图像说明文字

图0-1

为了实现这样的Walker对象:它能移动到任何一个相邻像素点上(还能呆在原地不动),我们可以从0~8(9种可能)的区间内选择一个随机数。不过,更好的实现方式应该是在x轴和y轴上分别选择3个可能的移动方向(-1、0或1)。

void step() {
    int stepx = int(random(3))-1;          生成 -1、0或1
    int stepy = int(random(3))-1;
    x += stepx;
    y += stepy;
}

进一步优化,我们可以用浮点数代替整型数作为x和y坐标值。并根据这个-1~1的随机浮点数确定移动方式。

void step() {
    float stepx = random(-1, 1);       生成介于-1.0~1.0的任意浮点数
    float stepy = random(-1, 1);
    x += stepx;
    y += stepy;
}

“传统”随机游走模型中的上述变量,都有一个共同点:在任意时刻,游走对象朝某一个方向移动的概率等于它朝其他任意方向移动的概率。比如,如果游走对象有4个可能的移动方向,它朝某个方向移动一步的概率就是1/4(25%);如果它有9个可能的移动方向,朝某个方向移动的概率是1/9(11%)。

简单地说,这就是random()函数的工作方式,Processing的随机数生成器产生的随机数是均匀分布的。我们可以用Sketch测试这种均匀分布:不断地产生某个区间内的随机数,并根据各个随机数的出现次数绘制柱状图。

图像说明文字

示例代码0-2 随机数的分布

int[] randomCounts;           数组存放了随机数被选中的次数

void setup() {
    size(640,240);
    randomCounts = new float[20];
}

void draw() {
    background(255);

    int index = int(random(randomCounts.length));       选择一个随机数,增加计数
    randomCounts[index]++;

    stroke(0);
    fill(127);
    int w = width/randomCounts.length;

    for (int x = 0; x < randomCounts.length; x++) {      绘制结果
        rect(x*w,height-randomCounts[x],w-1,randomCounts[x]);
    } 
}

上面的截图是本例运行几分钟后的结果。请注意柱状图中每个矩形的高度。我们选取的样本量(随机数个数)很少,有些偶然因素能使某些随机数被较多选中。如果有一个优秀的随机数生成器,随着时间推移和样本量的增加,整幅图将会被拉平。

伪随机数

我们从random()函数中取得的随机数并不是真正随机的,因此它们称为"伪随机数"。它们是由模拟随机的数学函数生成的。随着时间推移,这个函数将呈现出固定的模式,但那段时间很长,所以对我们来说,它的随机性已经足够了。

练习0.1

创建一个Walker对象,让它在游走过程中,有向下和向右移动的趋势。(我们将在下一节给出解决方案。)

0.3 概率和非均匀分布

还记得你第一次用Processing编程吗?或许你曾想在屏幕上画很多圆,然后告诉自己:“我打算在随机的位置,用随机的大小和颜色画这些圆!”在计算机图形系统中,用随机方式构建系统是最容易的。然而在本书中,我们打算对自然界建模,在这类场景中用随机方式构建模型是不合理的,尤其是对有机体或者具有自然外形的事物建模。

依靠一些技巧,我们可以改变使用random()函数的方式,使它产生“非均匀”分布的随机数。对本书后面的很多应用场景来说,这是一个飞跃性的改进:在遗传算法中,我们需要一种执行“选择”的方法——应该选择什么样的基因遗传给下一代?请记住,物种的进化过程存在优胜劣汰。举个例子,在一个处于进化阶段的猴子种群中,每只猴子的繁殖机会是不均等的。为了模拟达尔文的进化论,我们不能随机选择两只猴子作为父母,应该选择一些更“合适”的样本繁殖后代。我们需要定义“优胜劣汰的概率”模型。比如,一只强壮和灵活的猴子将有90%的繁殖可能性,而一只弱小的猴子只有10%的可能性。

让我们在这里暂停一下,先学学基本的概率理论。首先,我们要了解单次独立事件的发生概率,也就是单个事件发生的可能性。

如果某个过程会产生几种结果,其中某个事件发生的概率就等于该事件对应的结果数量除以所有结果的总数。抛硬币就是其中的一个简单例子——它只有正反两种结果:要么正面,要么反面。得到正面的事件概率等于1除以2,也就是1/2或50%。

从一副总共52张的扑克牌中抽出一张牌,抽到A的概率是:

A的数量/扑克牌总数 = 4/52 = 0.077 ≈8%

抽到方块的概率是:

方块的数量/扑克牌总数 = 13/52 = 0.25 = 25%

我们还可以计算序列中出现多个事件的概率,只要将所有单次事件发生的概率相乘即可。

连续抛硬币3次都得到正面的概率是:

(1/2) × (1/2) × (1/2) = 1/8=0.125

这意味着,要想连续3次抛硬币都得到正面,我们平均要尝试8次(每次尝试都抛3次硬币)。

练习0.2

从一副总共52张的扑克牌中抽出两张牌,两张都是A的概率是多少?

在代码中使用random()函数计算概率的方法有很多。一种常见的方法是:在数组中存放一堆选好的数字(其中某些数字是重复的),然后从这个数组中选择随机数,根据这些选择判定事件是否发生。

int[] stuff = new int[5]

stuff[0] = 1;              1在数组中存放了两次,被选中的可能性更高
stuff[1] = 1; 

stuff[2] = 2;
stuff[3] = 3;
stuff[4] = 3;

int index = int(random(stuff.length));          从数组中选择一个随机数

运行上述代码,我们有40%的概率得到1,20%的概率得到2,40%的概率得到3。

我们还可以只产生一个随机数(为了让问题变得更加简单,只考虑产生一个介于0~1的浮点随机数),并且假定仅当这个随机数落在一定区间内,指定的事件才发生。比如:

float prob = 0.10;       10%的概率

float r = random(1);     0~1的浮点型随机数

if(r < prob) {           如果选中的随机数小于0.1,就再试一次
    //再试一次
}

这个方法也可运用到多结果的情况。假定结果A有60%的概率会出现,结果B有10%的概率,结果C有30%的概率。我们只需要产生一个浮点型的随机数,然后检查随机数所在的范围,就能确定产生了哪个结果。

  • 随机数在0.00~0.60(60%)—>结果A
  • 随机数在0.60~0.70(10%)—>结果B
  • 随机数在0.70~1.00(30%)—>结果C
float num = random(1);

if (num < 0.6) {                   如果随机数小于0.6
    println("Outcome A");
} else if (num < 0.7) {            0.6~0.7
    println("Outcome B");
} else {
    println("Outcome C");            大于0.7
}

我们可以用上面的方法创建一个有右移趋势的Walker对象。这里有一个Walker对象,它的移动规律如下。

  • 上移的概率:20%
  • 下移的概率:20%
  • 左移的概率:20%
  • 右移的概率:40%

图像说明文字

示例代码0-3 有右移趋势的Walker对象

 void step() {

    float r = random(1);
    if (r < 0.4) {          有40%的概率向右移动
        x++;
    } else if (r < 0.6) {
        x--;
    } else if (r < 0.8) {
        y++;
    } else { 
        y--;
    } 
 }

练习0.3

创建一个有动态移动趋势的Walker对象。比如,你能不能写出一个有50%概率向鼠标所在方向移动的Walker对象?

0.4 随机数的正态分布

让我们回到模拟猴子种群的例子。你的程序生成了数以千计的猴子对象,每只猴子的身高都在200~300(在这个程序世界里,猴子的身高都在200~300像素)。

float h = random(200,300);

这个模型有没有准确地描述现实世界的情况?试想,在纽约市一个拥挤的人行道中,随便挑选一个路人,他的身高可能是随机的。但是,这种随机性和random()函数的随机性并不一样。人们的身高并不是均匀分布的,拥有平均身高的人数总是比特别高和特别矮的人多得多。为了更好地模拟自然情况,我们希望种群里的大部分猴子都接近平均身高(250像素),当然个别特别高和特别矮的猴子也是存在的。

所有的观测值都聚集在平均值附近,这样的分布称作“正态”分布。它还称作高斯分布(以数学家卡尔·弗里德里希·高斯命名),在法国正态分布称作拉普拉斯分布(以皮埃尔-西蒙-普拉斯命名)。这两个数学家同时在19世纪早期对正态分布进行了各自 的定义。

绘制正态分布时,你会看到类似下图的曲线,它一般称为钟形曲线。

图像说明文字

图0-2

图像说明文字

图0-3

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

估计平均值很容易,上面的例子中,对象的身高都在200~300,你可能直觉上认为平均身高就是250像素。但是,如果我说标准差是3或15,这对数据分布来说又意味着什么?上面的图例已经给了我们一定的暗示。左图向我们展示了标准差很小时的正态分布,在这种情况下,大部分数据都紧密集中在平均值附近。右图向我们展示了标准差很大时的正态分布,在这种情况下,数据相对分散地分布在平均值两边。

标准差的本质是这样的:给定一个种群,68%的个体数据分布在距平均值1个标准差的范围内,98%的个体数据分布在2个标准差的范围之内,99.7%的个体分布在3个标准差的范围之内。如果标准差是5个像素,只有0.3%的猴子身高小于235像素(比均值250小3个标准差)或大于265像素(比均值250大3个标准差)。

计算平均值和标准差

一个班有10名学生,在一次测试中,他们的成绩(满分为100分)如下:

85、82、88、86、85、93、98、40、73、83

成绩的平均值是:81.3

标准差是离均差平方和平均后的方根,具体的计算方法是:先求所有成绩减去平均成绩后的平方,再对所得的值求平均值(方差),最后把平均值开根号,就得到这组数据的标准差。

分数

和平均分的差

方差

85

85-81.3 = 3.7

(3.7)^2 = 13.69

40

40-81.3 = -41.3

(-41.3)^2 = 1705.69

...

 

 

 

标准差

254.23

标准差等于方差的平方根:15.13

我们非常幸运,在Sketch中求标准差并不需要自己进行上面的运算,运用Random类即可。这个类是Processing从Java库引入的(详细信息参考Random类的JavaDocs文档,网址为http://docs.oracle.com/javase/6/docs/api/java/util/ Random.html)。

为了使用Random类,我们必须声明一个Random类型的变量,然后在setup()函数中创建Random对象。

Random generator;      我们使用generator(生成器)作为变量名,因为此处可以认为是一个随机数生成器 

void setup() {
    size(640,360);
    generator = new Random();
}

如果我们想在draw()函数中生成一个符合正态分布的随机数,只需要简单地调用nextGaussian()函数。

void draw() {
    float num = (float) generator.nextGaussian();   返回一个高斯随机数(nextGaussian()返回值的类型是double,必须转型为float)
}

重点在下面。我们要用这个随机数做什么?如果我们以它为x坐标绘制某个图形,会有怎样的效果?

nextGaussian()函数默认以下面两个参数生成符合正态分布的随机数:正态分布的平均值等于0,标准差等于1。如果我们需要一个平均值为320(宽度为640的窗口的正中位置),标准差为60像素的正态分布,可以简单地处理参数:将它乘以标准差并加上平均值。

图像说明文字

示例代码0-4  高斯分布

void draw(){
    float num = (float) generator.nextGaussian();   注意,nextGaussian()的返回值类型是double

    float sd = 60;
    float mean = 320;

    float x = sd * num + mean;     乘以标准差,再加上平均值

    noStroke();
    fill(255, 10);
    ellipse(x,180,16,16);
}

在所得的x坐标上绘制半透明的椭圆,让这些椭圆相互叠加,我们可以看到正态分布的效果:颜色最深的点出现在中间,因为随机值都集中在这里,但偶尔也有一些图形画在两边。

练习0.4

思考怎么用各种颜色的点模拟颜料飞溅在画板上的效果,大部分点都画在中间位置,也有一部分点画在边缘位置。你能用正态分布的随机数产生这些点的位置吗?用这些随机数产生一个调色板呢?

练习0.5

在高斯随机游走模型中,每次的移动长度(每次物体在指定方向的移动距离,即步长)都是根据正态分布产生的,试着在我们的随机游走模型中实现这样的特性。

0.5 自定义分布的随机数

生活中总有很多例子无法用均匀分布的随机数模拟,高斯分布有时也无能为力。假设你是一个正在觅食的随机游走者,在某个空间内随机移动貌似是一种合理的觅食策略。毕竟,你不知道食物在哪里,不如走一步算一步。但你会发现一个问题,随机游走者经常会走回原先涉足过的地方(这称为“过采样”)。有一个策略可以避免这个问题:每隔一段时间,跨很大一步。这样就可以让在一个特定范围内的游走者时常跳到很远的地方,以减少过采样。要实现这样的随机游走(成为列维飞行),首先要有一堆自定义的概率值。但这不是列维飞行的一个标准实现。我们可以这么定义概率分布:步子越长,发生的概率越小;步子越短,发生的概率越大。

在本章开始的时候,我们曾这样获取自定义分布的随机数:从一个数组中选择事先填充好的数字(某些数字有重复,这些数字被选中的概率更大),或者判定random()函数返回的结果。我们也可以用类似的方式实现列维飞行,假定随机游走者有1%的几率跨一大步。

float r = random(1);

if(r < 0.01){       有1%的几率跨一大步
    xstep = random(-100, 100);
    ystep = random(-100, 100);
}else{
    xstep = random(-1, 1);
    ystep = random(-1, 1);
}

但是,这把我们限制在了有限的几个选择中。如果我们想要有一般的选择规则(数字越大,被选到的概率越大),该怎么做?比如,3.145被选中的几率就比3.144高,就算只高一点点。换言之,以选中的随机数为x轴,被选中的概率为y轴,我们可以建立这样的映射:y = x

图像说明文字

图0-4

如果能得到这类自定义分布的随机数生成算法,我们就可以用同样的方式计算各种公式对应的分布曲线。

一种常见的解决方案是:生成两个随机数,而不是只生成一个随机数。第一个随机数只是一个普通的随机数。第二个随机数我们称作“资格随机数”,用来决定第一个随机数的取舍。那些资格更高的随机数被选中的概率更大,而资格更低的随机数被选中的概率更小。下面是计算步骤(只考虑位于0~1的随机数):

1.选择一个随机数R1;
2.计算R1被选中的资格概率P,假设P = R1;
3.选择另一个随机数R2;
4.如果R2小于P,那么R1就是我们要的随机数;
5.如果R2大于P,回到第(1)步重新开始。

在本例中,一个随机数被选中的资格概率的大小等于其本身。假如我们选中的R1是0.1,这意味着R1被最后选中的概率是10%。如果R1是0.83,那么它有83%的概率被最后选中。数字越大,最后被选择的概率也越大。

以下函数(称为蒙特卡洛算法,以蒙特卡洛大赌场命名)实现了上面的算法,返回0~1的随机数。

float montecarlo(){
    while (true){             “永远”重复这个操作,直到合格的随机数被找到 

        float r1 = random(1);       选择一个随机数

        float probability = r1;     分配概率

        float r2 = random(1);        选择第二个随机数

        if (r2 < probability){     这个随机数是否有资格被选中?如果是,任务完成
            return r1;
        }
    }
}

练习0.6

用一种自定义分布确定随机游走的步长,步长可以根据选中值的范围来确定。你能否通过某种映射来确定选中的概率,比如,选择的概率等于它的平方。

float stepsize = random(0,10);     步长大小的均匀分布。改变这个!
float stepx = random(-stepsize,stepsize); float stepy = random(-stepsize,stepsize);
x += stepx; y += stepy;
(在后面,我们会用向量重新实现这个程序。)

0.6 Perlin噪声(一种更平滑的算法)

一个好的随机数生成器能产生互不关联且毫无规律的随机数。跟我们前面看到的一样,一定程度的随机性有利于有机体和生命活动的建模。然而,单独把随机性作为唯一指导原则是不够的,它并不完全符合自然界的特征。有个算法叫“Perlin噪声”,它就将这一点考虑在内了,该算法是以Ken Perlin命名的。20世纪80年代初,Ken Perlin曾参与电影《电子世界争霸战》(Tron)的制作,在此期间他发明了Perlin噪声算法,用于生成纹理特效。1997年,Perlin因此获得了奥斯卡技术成就奖。Perlin噪声算法可用于生成各种自然特效,包括云层、地形和大理石的纹理。

Perlin噪声算法表现出了一定的自然性,因为它能生成符合自然排序(“平滑”)的伪随机数序列。图I-5展示了Perlin噪声的效果,x轴代表时间;请注意曲线的平滑性。图I-6展示了纯随机数的效果。(生成图形的代码可以在本书的下载资料中找到。)

图像说明文字

图0-5 噪声

图像说明文字

图0-6 随机

Processing内置了Perlin噪声算法的实现:noise()函数。noise()函数可以有1~3个参数,分别代表一维、二维和三维的随机数。我们先从一维的noise()函数开始了解。

Perlin噪声

Processing的noise()函数(http://processing.org/reference/noise_.html)告诉我们噪声是通过几个“八度”计算出来的。调用noiseDetail()函数(http://processing.org/reference/noiseDetail_.html)会改变“八度”的数量以及各个八度的重要性,这反过来会影响noise()函数的行为。

Ken Perlin有个在线讲座,能让你了解更多的噪声原理(http://www.noisemachine.com/talk1/)。

考虑在Processing窗口中以随机的x坐标画一个圆:

float x = random(0, width);          一个随机的x坐标
ellipse(x, 180,16,16);

现在,用一个“更平滑”的Perlin噪声作为x坐标,替代原先的随机值。你可能会觉得只需将random()函数替换为noise()函数,比如:

float x = noise(0, width);    噪声的x坐标?

从概念上说,我们确实只需要用Perlin噪声算法得到0和窗口宽度之间的一个x坐标,但这并不是一个正确的实现。random()函数的参数是目标随机数的最小值和最大值,但是noise()函数并非如此。noise()函数的结果范围是固定的,它总是会返回一个介于0~1的结果。后面我们会通过Processing的map()函数来改变结果的范围,在此之前,先来了解noise()函数的参数。

我们可以把一维的Perlin噪声当作随着时间推移而发生变化的线性序列,比如:

时间 噪声值
0 0.365
1 0.363
2 0.363
3 0.364
4 0.366

为了在Processing中得到某个时间点上的噪声值,我们必须传入noise()函数一个“指定的时间点”,比如:

float n = noise(3);

根据上面的表格,noise(3)会在时间点3返回0.364。为了能用draw()函数获取不同时刻的噪声值,我们可以传入一个时间变量作为参数。

float t = 3;

void draw(){
    float n = noise(t);           返回指定时间点的噪声值
    println(n);
}

上面的代码每次都会输出一样的结果。因为我们每次都在noise()函数中传入一个固定的时间点——3。递增时间变量t,我们就能得到不同的结果。

float t = 0;             一般从时间点0开始,但这个值可以是任意的

void draw(){
    float n = noise(t);
    println(n);
    t+=0.01;               随时间向前移动
}

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

图像说明文字

图0-7

试着多次运行上面的代码,分别以0.01、0.02、0.05、0.1、0.0001的增量增大t,你会看到不同的结果。

0.6.1 映射噪声

接下来,我们开始研究如何处理得到的噪声值。得到0~1的噪声值之后,我们需要将它映射到我们想要的范围内。最方便的方法是使用Processing的map()函数。map()函数有5个参数。第一个参数是我们想要映射的值(这里即n),后面的两个参数是该值原来的范围(最大值和最小值),最后两个参数是目标范围。

图像说明文字

图0-8

在本例中,我们知道噪声函数的返回值在0~1的范围内,但我们想要在0到窗口宽度的范围内画这个圆。

float t = 0;

void draw() {
    float n = noise(t);
    float x = map(n,0,1,0,width);       用map()函数定制Perlin噪声的范围
    ellipse(x,180,16,16);

    t += 0.01; 
}

我们可以将这个逻辑运用到随机游走模型中,用Perlin噪声同时生成x坐标和y坐标。

图像说明文字

示例代码0-5 Perlin噪声游走模型

class Walker {
    float x,y;

    float tx,ty;

    Walker() {
        tx = 0;
        ty = 10000; 
    }

    void step() {
        x = map(noise(tx), 0, 1, 0, width);       噪声映射后的x和y坐标
        y = map(noise(ty), 0, 1, 0, height);

        tx += 0.01;     随着时间向前推进
        ty += 0.01;
    } 
}

请注意上面的例子是如何使用txty这对变量做参数的。我们同时需要跟踪两个时间变量,一个用于产生游走对象的x坐标,另一个用于产生y坐标,但是这两个变量还有一些奇怪的地方,为什么tx从0开始,而ty从10 000开始?尽管这两个初始值是随意确定的,但我们故意用了不同的值来初始化这两个时间变量。这是因为噪声函数的返回结果是确定的:无论何时调用它,只要传入的时间点t相同,返回的结果也相同。如果我们通过同一个时间点t获取两个坐标,返回的x坐标和y坐标会是相等的,这意味着游走对象Walker只会在一条对角线上移动。在这里,我们用了噪声空间的两个不同区域,x坐标对应的区域从0开始,y坐标对应的区域从10 000开始,这样x坐标和y坐标就会彼此独立。

图像说明文字

图0-9

实际上,Perlin噪声是没有时间轴这个概念的。为了让大家更容易地理解噪声函数的工作方式,我引入了时间轴这个隐喻。但是,我们应该有空间的概念,而不该有时间轴的概念。上图描述了噪声序列在一维空间上的排列,我们可以获取任意x坐标上的噪声值。比如,你经常会在噪声图中看到一个叫xoff的变量,它表示x轴上的偏移量,取代上面说的时间点变量t(见图表注解)。

练习0.7

在上面的随机游走模型中,噪声函数的返回值被映射到游走对象所在的位置。请创建这样的游走模型:它的移动步长是由noise()函数返回值映射得到的。

0.6.2 二维噪声

一维空间上的噪声值很重要,它将我们引入了对二维噪声的讨论。在一维噪声中,噪声序列中的邻近噪声值都非常接近,因为在一维空间中,每个点只有两个相邻点:前一个点(在图中位于左侧)和后一个点(位于右侧)。

图0-10 一维噪声

图0-11 二维噪声

从概念上看,二维噪声的工作方式是完全一样的。唯一的不同在于:二维噪声从线性空间转到了网格空间。思考下面的场景:一张纸上有个表格,在表格的每个单元格里写一个数字,每个单元格的数字都接近和它相邻单元格上的数字,即上下左右和对角线上的值。

试着把表格上的数据可视化:把单元格上的数字映射成色彩亮度,你就能看到云状的图形。在图形中,白色和浅灰色相邻、浅灰色和灰色相邻、灰色和深灰色相邻、深灰色又和黑色相邻,以此类推,参见下图。

图像说明文字

这就是噪声最先被引入时的用途。只要稍微改变一下参数,你就可以创造出有大理石、树木和其他自然纹理效果的图像。

让我们看看如何在Processing中使用二维噪声。如果要给窗口中的每个像素着上随机的颜色,你要写一个循环,在循环中遍历每个像素点并选择一个随机的亮度。

loadPixels();
for (int x = 0; x < width; x++) {
    for (int y = 0; y < height; y++) {

        float bright = random(255);      随机亮度 
        pixels[x+y*width] = color(bright);
    }
}
updatePixels();

下面要根据noise()函数的返回值为像素着色,我们只需要调用noise()函数,取代原先的random()函数。

float bright = map(noise(x,y),0,1,0,255);     由Perlin噪声算法产生的亮度!

从表面上看,这并没有问题,你会在二维空间上的每个(x,y)位置得到对应的噪声值。但问题是我们并不能由此得到云质感的效果。对噪声函数来说,从200到201像素会造成很大的参数跳跃。还记得吗,在一维噪声中,我们每次以0.01的增幅递增时间变量,并不是1这么大的增量。对此,我们可以用不同的变量作为噪声函数的参数,这样就可以解决这个问题。比如,我们可以增加xoffyoff变量:在循环遍历过程中,如果有水平方向的移动,就以合适的增量递增xoff,如果有竖直方向的移动,就递增yoff

示例代码0-6 二维Perlin噪声

float xoff = 0.0;      xoff从0开始

for (int x = 0; x < width; x++) {
    float yoff = 0.0;      对每个xoff,yoff从0开始

        for (int y = 0; y < height; y++) {
            float bright = map(noise(xoff,yoff),0,1,0,255);    将xoff和yoff传入noise()函数

            pixels[x+y*width] = color(bright);    将x和y作为像素位置

            yoff += 0.01;    增加yoff
        }
        xoff += 0.01;        增加xoff
}

练习I.8

在上面的例子中,试着改变颜色,调用noiseDetail()函数,调整xoffyoff的递增幅度,来看看不同的视觉效果。

练习 0.9

在噪声函数中传入第三个参数,并在每一轮draw()函数中递增这个参数,观察二维噪声的动态效果。

练习 0.10

把噪声值当作地平线高度,画出噪声地形图。请参考下面的截图:

图像说明文字

我们在此处学习了Perlin噪声的几种常规用法。对一维噪声,我们把平滑的噪声值当作物体的位置,并由此描绘游走的轨迹。对二维噪声,我们用平滑的噪声值制作了一副有云纹理的图形。请记住,Perlin噪声值仅仅是一组数据,并不一定是像素位置或者色彩亮度。本书中的任何例子都有可能用到Perlin噪声。比如,当我们在对风力进行建模时,风力的大小就是由Perlin噪声生成的;同样的,分形模型中树枝之间的角度,还有模拟流场时物体的速度和方向,都可能是由Perlin噪声生成的。

图像说明文字

图0-12 由Perlin噪声产生的树

图像说明文字

图0-13 由Perlin噪声产生的流场

0.7 前进

在本章的开头,我们讨论了随机数如何在模拟过程中扮演万能角色。在很多场景中,我们提出的各种问题都可以简单地用随机来解决,比如如何移动一个物体,再比如用什么颜色描绘物体。随机是我们首先会想到的答案,但同时也是一个偷懒的回答。

最后需要特别指出,我们很容易掉进另外一个陷阱,就是把Perlin噪声也当成解决问题的万能方法。如何移动一个物体?用Perlin噪声!用什么颜色渲染像素?Perlin噪声!生长速度有多快?还是Perlin噪声!

在这里,关键点并不在于要不要用随机方法,也不在于要不要用Perlin噪声。关键是构建系统的规则是你自己定义的,手头上的工具越多,可用于实现这些规则的方法也就越多。本书的目的就是填充你的“工具箱”。如果只知道随机方法,你的设计思路会因此受限。尽管Perlin噪声能提供很多帮助,但你还是需要掌握更多工具——非常多的工具。

我想我们已经做好了开始的准备。

目录