第 1 章 向量

第 1 章 向量

“收到,收到。维克多,我们的航向指示(vector)是什么?”

——Oveur机长(电影《空前绝后满天飞》)

本书主要通过观察周围的世界,提出一些巧妙的方法来利用代码对其建模。本书主要分为3部分,在第一部分,我们研究基础物理学,比如苹果怎么会从树上掉下来,钟摆如何在空中摆动,地球如何围绕太阳转动,等等。本书的前5章内容都离不开运动建模的基本组件——向量(vector)。我们的故事也从向量开始。

如今,vector一词有很多含义。它是20世纪80年代初加州萨克拉门托的一支新浪潮摇滚乐队的名称,还是加拿大凯洛格公司生产的一种早餐麦片的品牌。在流行病学中,vector(媒介)是将传染病从一个宿主传播到另一个宿主的有机体;在C++编程语言中,vector(std::vector)代表可动态增长的数组。以上这些定义都非常有趣,但它们并不是我们要研究的话题。我们要谈论的是欧几里得向量(Euclidean vector,以希腊数学家欧几里得的名字命名,也称作几何向量),本书中出现的“向量”均指欧几里得向量,它的定义是:一个既有大小又有方向的几何对象。

向量通常被绘制为一个带箭头的线段,线段的长度代表向量的大小,箭头所指的方向就是向量的方向。

图1-1 一个向量(绘制成带箭头的线段)有大小(线段的长度)和方向(箭头所指的方向)

在上图中,向量被绘制为从A点到B点的带箭头的线段,并说明了物体如何从A点运动到B点。

1.1 向量

在深入探究向量这个概念之前,我们先从一个例子入手,看看为什么要把向量放在如此重要的位置。如果你读过关于Processing的介绍性书籍或上过Processing编程课(我非常希望你在看本书之前已经做了这些准备),你可能学过如何在Sketch中写一个简单的弹球模拟程序。

图像说明文字

如果你阅读的是本书的PDF版或印刷版,那么只能看到代码运行结果的截图。而运动是本书谈论的重点,因此,为了凸显运动效果,我尽可能在静态截图中加上了弹球的运动轨迹。如果你想知道如何绘制运动轨迹,请下载本例的源代码

示例代码1-1 没有使用向量的弹球程序

float x = 100;          小球的位置和速度变量          
float y = 100;
float xspeed = 1;
float yspeed = 3.3;

void setup() {          还记得Processing的运行方式吗?
    size(200,200);        setup()函数在Sketch启动时被调用
    smooth();             draw()函数在退出之前一直被调用      
    background(255);
}

void draw() {
    background(255);

    x = x + xspeed;           根据速度移动小球
    y = y + yspeed;

    if ((x > width) || (x < 0)) {        检查边缘,改变运动方向
        xspeed = xspeed * -1;
    }
    if ((y > height) || (y < 0)) {
        yspeed = yspeed * -1;
    }

    stroke(0);
    fill(175);
    ellipse(x,y,16,16);          在(x,y)位置绘制小球
}

上例中,我们在空白的画板上创建了一个到处移动的圆球。它有很多属性,在代码中,我们用变量表示它的属性。

位置                              y*
速度                              xspeedyspeed

在以后更高级的例子中,我们还可以加入这些变量:

加速度                         xaccelerationyacceleration
目标位置                     xtargetytarget
风                                xwindywind
摩擦力                         xfrictionyfriction

从中可以看出,对于自然界的每个类似概念(风、位置、加速度等),我们都需要用两个变量表示。这只是在二维世界中,如果在三维世界中,我们就需要用3个变量表示,如xyz,以及xspeedyspeedzspeed,等等。

如果我们能简化这些代码并使用更少的变量,岂不是很好?

对于下面这些变量:

float x;
float y;
float xspeed;
float yspeed;

可以把它们替换成:

Vector location;
Vector speed;

在这里引入向量并不会给我们增添新工作,单纯地在代码中加入向量也不会让Sketch自己去模拟物理现象。但是,它会简化你的代码,对于在运动模拟中经常出现的数学运算,向量提供了很多现成的函数。

学习向量的相关知识时,我们将使用二维空间(至少在本书的前几章)。所有这些例子都可以轻松地扩展到三维空间(我们使用的PVector 类也适用于三维空间)。但是,从二维空间入手比较容易。

1.2 Processing中的向量

我们可以把向量当作两点之间的差异,也就是从一个点到另一个点所发生的移动。

下面是几个向量及它们的可能解释:

图像说明文字

图1-2

(-15,3)                  向西走15步,然后向北走3步
(3,4)                      向东走3步,然后向北走4步
(2,-1)                    向东走2步,然后向南走1步

在前面的运动模拟例子中,你已经做过这方面的编程了:在每一帧动画中(Processing的draw()循环体),我们曾经让屏幕上的对象分别在水平和竖直方向移动了几个像素到达新位置。

图1-3

对每一帧动画:

新位置 = 当前位置在速度作用下的位置

如果速度是一个向量(两点之间的差异),那位置是否也是一个向量?从概念上说,有人会争论说位置并不是向量,因为它没有描述从某个点到另一个点的移动,它只描述了空间中的一个点而已。

然而,对于位置这个概念,另一种描述是从原点到该位置的移动路径。因此位置也可以用一个向量表示,它代表原点与该位置的差异。

图1-4

让我们来看看位置和速度背后的数据。在弹球例子中:

位置                xy
速度              xspeedyspeed

请注意我们如何存储位置和速度数据:用两个浮点数,一个浮点数代表x坐标,另一个浮点数代表y坐标。如果我们自己写个类来表示向量,那么可以这么开始:

class PVector {

    float x;
    float y;

    PVector(float x_, float y_) {
        x = x_;
        y = y_; 
    }
}

在这里,PVector只是存储了两个变量(或者三维世界中的3个变量)的简单数据结构。

之前的初始化过程:

float x = 100;
float y = 100;
float xspeed = 1;
float yspeed = 3.3;

变成了:

PVector location = new PVector(100,100);
PVector velocity = new PVector(1,3.3);

既然我们已经有了位置和速度这两个向量对象,接下来就可以开始实现最基本的运动模拟:新位置 = 原位置 + 速度。 示例代码1-1没有用到向量,我们是这么做的:

x = x + xspeed;         将速度与当前位置相加
y = y + yspeed;

在理想情况下,我们希望用下面的代码完成同样的操作:

location = location + velocity;         将速度向量与位置向量相加

然而,在Processing语言中,加号(+)操作符是为原生数据类型(整数、浮点数等)预留的。Processing并不知道如何将两个PVector对象相加,就像它也不知道如何将两个PFont对象或PImage对象相加一样。但幸运的是,PVector类可以包含一些常用的数学操作函数。

1.3 向量的加法

在继续学习PVector类和它的add()方法(Processing已经替我们实现了这个函数)之前,让我们先从数学和物理学的角度学习向量加法的原理。

向量通常用粗体或者顶上带箭头的字母表示。在本书中,为了能区分向量标量(标量指单个值,就像整数或浮点数),我们用带箭头的方式表示一个向量:

  • 向量:\vec{u}
  • 标量:x

如果我们有下面两个向量: 图像说明文字

图1-5

每个向量都有两部分数据——xy。为了把这两个向量加在一起,我们只需简单地将它们的xy分别相加。

图像说明文字

图1-6

也就是说:

\vec{w} = \vec{u}+\vec{v}

可以表示为:

w_{x}=u_{x}+v_{x}

w_{y}=u_{y}+v_{y}

然后,把uv替换成它们在图1-6中对应的值:

w_{x} =5+3

这意味着,最后,我们得到相加后的向量:

\vec{w}= (8,6)

既然我们已经学会了如何将两个向量相加,接下来,就可以尝试着用代码实现PVector类的加法操作。添加一个add()函数,这个函数的参数是被相加的PVector对象:

class PVector {

    float x;
    float y;

    PVector(float x_, float y_) {
        x = x_;
        y = y_; 
    }

    void add(PVector v){  该函数将两个向量相加,只需简单地将x、y分量分别相加
        y = y + v.y;
        x = x + v.x;
    }
}

PVector中实现add()函数之后,我们可以把它运用到之前的弹球程序中,用向量加法实现位置+速度的算法:

location = location + velocity;    为位置加上当前速度
location.add(velocity);

好了,下面我们可以用PVector类重写弹球程序了。

示例代码1-2 用PVector对象实现弹球程序

PVector location;    用PVector对象表示速度,代替之前的浮点数,现在我们有两个PVector变量
PVector velocity;

void setup() {
    size(200,200);
    smooth();
    location = new PVector(100,100);
    velocity = new PVector(2.5,5);
}

void draw() {
    background(255);

    location.add(velocity);

    if ((location.x > width) || (location.x < 0)) {   如果我们要引用向量的两个分量,可以用location.x、velocity.y等点语法引用它们
        velocity.x = velocity.x * -1;
    }
    if ((location.y > height) || (location.y < 0)) {
        velocity.y = velocity.y * -1;
    }

  stroke(0);
  fill(175);
  ellipse(location.x,location.y,16,16);
}

你可能会感到有点失望。做了这么多,我们却没有让代码变简单,反而让它比原来的版本更复杂了。虽然这是一个完全合理的质疑,但你也需要清楚地知道,我们还没有充分见识到向量编程的真正威力。看看上面的弹球程序,实现向量的加法只是第一步。当我们继续深入,接触到由多个物体和多种(我们将在第2章介绍它)组成的复杂情形时,PVector类的好处就会突显出来。

在上面的代码变化中,你还应该注意到一个关键点。尽管我们用PVector对象描述两个变量——位置的x坐标和y坐标以及速度的x分量和y分量,但有时候还是需要单独引用PVector类中的x变量和y变量。比如,如果要在Processing中绘制一个物体,我们不能这么做:

ellipse(location,16,16);

ellipse()函数不能接受PVector对象作为它的参数。我们只能传入两个标量值作为参数:一个x坐标和一个y坐标。因此,我们可以采用面向对象的点语法从PVector对象中分别获取它的x变量和y变量。

ellipse(location.x, location.y, 16, 16);

在判断对象是否达到窗口的边缘时,我们会碰到一样的问题。同样,我们需要访问位置向量和速度向量内部的两个变量:

if((location.x > width) || (location.x < 0)){  
    velocity.x = velocity.y * -1;
}

练习1.1

在前面的Processing编程例子中,找一个单独使用xy变量的场景,用PVector对象重新实现它。

练习1.2

挑选引言中的一个随机游走示例,用PVector对象改造它。

练习1.3

将用向量实现的弹球程序扩展到三维空间。你能否模拟球体在一个箱子内反弹的效果?

1.4 更多的向量运算

上面的向量加法只是一个开始,除了加法,还有很多常用的向量运算。下面的列表给出了PVector类中的所有函数及对应的向量运算。我们会重点学习几个关键函数。在后续章节中,随着接触的例子变得越来越复杂,我们也会继续介绍更多的向量运算函数。

  • add()                    向量相加

  • sub()                    向量相减

  • mult()                  乘以标量以延伸向量

  • div()                    除以标量以缩短向量

  • mag()                    计算向量的长度

  • setMag()              设定向量的长度

  • normalize()         单位化向量,使其长度为1

  • limit()                限制向量的长度

  • heading2D()         计算向量的方向,用角度表示

  • rotate()               旋转一个二维向量

  • lerp()                  线性插值到另一个向量

  • dist()                  计算两个向量的欧几里得距离

  • angleBetween()    计算两个向量的夹角

  • dot()                    计算两个向量的点乘

  • cross()                计算两个向量的叉乘(只涉及三维空间)

  • random2D()          返回一个随机的二维向量

  • random3D()          返回一个随机的三维向量

我们已经在前面学习了向量的加法,下面开始研究向量的减法。减法很简单,只是把之前的加号换成减号而已!

1.4.1 向量的减法

\vec{w} = \vec{u}-\vec{v}

可以写成:

w_{x}=u_{x}-v_{x}

w_{y}=u_{y}-v_{y}

图像说明文字

图1-7 向量的减法

PVector类中,减法函数是这么实现的:

void sub(PVector v) {
    x = x - v.x;
    y = y - v.y;
}

下面的例子展示了向量减法,它实现的功能是:求屏幕中心点与鼠标所在点之间的差。

图像说明文字

示例代码1-3 向量减法

void setup() {
    size(200,200);
}

void draw() {
    background(255);
    PVector mouse = new   PVector(mouseX,mouseY);   两个向量,一个表示鼠标位置,
    PVector center = new  PVector(width/2,height/2);   一个表示窗口中心

    mouse.sub(center);       向量的减法!

    translate(width/2,height/2);     绘制一条线段表示向量
    line(0,0,mouse.x,mouse.y);
}

1.4.2 向量加减法的运算律

向量加减法的运算律和普通数值的运算率一样。

交换律:\vec{u}+\vec{v}=\vec{v}+\vec{u}

结合律:\vec{u}+ (\vec{v}+\vec{w}) =(\vec{u}+ \vec{v})+\vec{w}

将上面晦涩难懂的术语放在一边,这只是一个很简单的概念。一句话,向量的运算和普通的运算在这里没什么区别。

3+2=2+3

(3+2)+1 = 3+(2+1)

1.4.3 向量的乘法

在介绍向量的乘法之前,我们必须指出概念上的一点点不同。当我们说向量的乘法时,一般指的是改变向量的长度。如果想让某个向量的长度延伸为原来的两倍,或者缩短为原来的1/3,我们会说“将向量乘以2”或者“将向量乘以1/3”。注意,这里我们是把向量乘以一个标量,而不是乘以另一个向量。

图1-8 改变向量的长度

为了改变向量的长度,我们将向量的各部分分别乘以标量:

\vec{w}= \vec{u}* n

等同于:

w_x = u_x * n

w_y = u_y * n

用向量分量表示向量的乘法:

\vec{u}=(-3,7)

n=3

\vec{w}=\vec{u}*n

w_x=-3*3

w_y =7 * 3

\vec{w}=(-9,21)

因此,在PVector类中,向量的乘法可以这样实现:

void mult(float n) {
    x = x * n;    在向量乘法中,两个分量分别乘以某个数字
    y = y * n;
}

在具体应用中,调用乘法函数也很简单:

PVector u = new PVector(-3,7);    PVector的长度变成了原来的3倍,现在等于(-9,21)
u.mult(3);

图像说明文字

示例代码1-4 向量乘法

void setup() {
    size(800,200);
    smooth();
}

void draw() {
    background(255);

    PVector mouse = new PVector(mouseX,mouseY);
    PVector center = new PVector(width/2,height/2);
    mouse.sub(center);

    mouse.mult(0.5);     向量的乘法运算!向量的长度变成了原来的一半(乘以0.5)

    translate(width/2,height/2);
    line(0,0,mouse.x,mouse.y);
}

除法和乘法一样,只需要将乘号(星号)换成除号(正斜杠)。

图1-9

void div(float n) {
    x = x / n;
    y = y / n;
}

PVector u = new PVector(8,-4);
u.div(2);      向量除以2!现在向量是原大小的一半

1.4.4 更多的向量运算律

和向量的加法一样,基本的代数运算律也适用于向量的乘除法。

结合律: (n * m)*\vec{v}= n * (m*\vec{v})

两个标量和一个向量之间的分配律:(n * m)+\vec{v}= n *\vec{v}+m*\vec{v}

两个向量和一个标量之间的分配律:(\vec{u}+\vec{v}) * n = \vec{u} * n+\vec{v}* n

1.5 向量的长度

乘除法可以改变一个向量的长度,同时使向量的方向保持不变。你可能会疑惑:那我怎么知道这个向量的长度是多少?我知道它的x分量和y分量,但它到底有多长呢(以像素为单位)?理解向量长度的计算原理是非常有用的。

图1-10 向量\vec(v)的长度通常表示为:∥\vec{v}

在上图中,向量本身和它的两个分量(x分量和y分量)围成了一个直角三角形。三角形的直角边是它的两个分量,斜边是它本身。我们非常幸运能够拥有这个直角三角形,因为希腊数学家毕达哥拉斯提出了一个有趣的公式(勾股定理),这个公式揭示了直角三角形两条直角边和斜边之间的关系。

图1-11 勾股定理

如图,勾股定理就是a的平方加上b的平方等于c的平方。

有了这个公式,我们就可以用下面的方法计算一个向量的长度:

||\vec{v}||=\sqrt{v_x*v_x+v_y*v_y}

PVector中,我们这么实现它:

float mag() {
    return sqrt(x*x + y*y);
}

图像说明文字

示例代码1-5 向量的长度

void setup() {
    size(800,200);
    smooth();
}

void draw() {
    background(255);

    PVector mouse = new PVector(mouseX,mouseY);  
    PVector center = new PVector(width/2,height/2);
    mouse.sub(center);

    float m = mouse.mag();   可以通过mag()函数计算向量的长度。借助mag()函数,这段代码在窗口顶部绘制了一个矩形
    fill(0);
    rect(0,0,m,10);

    translate(width/2,height/2);
    line(0,0,mouse.x,mouse.y);

}

1.6 单位化向量

计算向量的长度只是一个开始。长度计算函数引入了更多的向量运算,第一个就是单位化。单位化也称为正规化,正规化就是把某种事物变成“标准”或“常规”状态。一个“标准”的向量就是长度为1的向量。因此,将一个向量单位化,就是使它的方向保持不变,但长度变为1,这样的向量称为单位向量

图1-12

由于单位向量描述了一个向量的方向,又不用关心长度,所以,获取一个向量的单位向量是非常有用的操作。在第2章有关力的话题中,我们将看到它的作用。

对于一个给定的向量\vec{u},它的单位向量(表示成\hat{u})可以通过以下方法计算得到:

\hat{u}=\frac{\vec{u}}{||\vec{u}||}

也就是说,要单位化一个向量,我们只需要将它的每个分量除以它的长度。下图中,有一个长度为5的向量,为了将其单位化,在对应的直角三角形中,我们需要将斜边的长度缩短到1,也就是将它除以5,这样三角形的各条边都缩短为原来的1/5。

图1-13

PVector类中,我们这么实现向量的单位化:

 void normalize() {
    float m = mag();
    div(m);
}

这里还有个小问题。如果向量的长度为0,会发生什么?我们不能将一个数除以0!我们可以在代码中加入除0判断以修复这个问题:

void normalize() {
    float m = mag();
    if (m != 0) {
    div(m); 
    }
}

图像说明文字

示例代码1-6 单位化向量

void draw() {
    background(255);

    PVector mouse = new PVector(mouseX,mouseY);
    PVector center = new PVector(width/2,height/2);
    mouse.sub(center);

    mouse.normalize();  向量被单位化后,为了让它在屏幕上可见,我们将它乘以50。注意,无论鼠标在哪里,向量的长度总是等于50

    mouse.mult(150);
    translate(width/2,height/2);
    line(0,0,mouse.x,mouse.y);
}

1.7 向量的运动:速度

上面说到的向量运算是我们必须掌握的基础知识,为什么它们如此重要?它们对编码有何帮助?对此我们要有一点点耐心。在能完全使用PVector类的强大功能之前,我们还需要走很长的路。掌握任何新的数据结构,都需要一段漫长的学习过程。举个例子,你刚开始学习数组时可能会觉得,比起用多变量实现功能,使用数组貌似要做更多的事情,但当涉及成百上千的变量时,数组的作用就马上显现出来了。对于PVector类,情况也是如此。现在的学习会在以后给你带来更好的收益。对于向量的运算,你不必等太长时间,因为在下一章我们就会得到回报。

我们先从简单的例子入手。如何用向量对运动进行编程模拟?示例代码1-2是一个弹球程序,在这个例子中,屏幕中的对象有自己的位置(任意给定时刻的位置)和速度(物体在下一秒该如何运动),位置加上当前速度可以得到一个新的位置:

location.add(velocity);

然后,我们在这个新的位置上绘制对象:

ellipse(location.x,location.y,16,16);

这就是我们要介绍的第一个运动模拟程序(我们称为运动101):

1.当前位置加上速度得到一个新的位置;

2.在新的位置上绘制对象。

在弹球程序中,所有代码都写在Processing主标签页的setup()函数和draw()函数中。我们下面要做的就是把运动的逻辑封装到一个中。通过这种方式,我们可以构建一个与物体运动相关的基础类库。在I.2节,我们简要地回顾了面向对象编程的基础。本书假定你有使用Processing对象和类的经验。如果你需要复习一下这些基础,我建议你看看Processing对象教程(http://processing.org/learning/objects/)。

我们将创建一个通用的Mover类,用来实现物体在屏幕上的运动模拟。在此之前,我们必须考虑下面两个问题:

1.Mover有哪些数据;

2.Mover有哪些功能。

运动101的模拟程序已经告诉我们这两个问题的答案。一个Mover对象有两部分数据:位置和速度,这两个数据都是PVector对象。

class Mover {

    PVector location;
    PVector velocity;

Mover对象的功能也很简单。它需要能够移动,还需要被显示出来。我们将这两个功能实现为update()函数和display()函数。所有的运动逻辑代码都放在update()函数中,而显示代码放在display()函数中。

    void update() {
        location.add(velocity);       Mover对象开始移动
    }

    void display() {
        stroke(0);
        fill(175);
        ellipse(location.x,location.y,16,16);       绘制Mover对象
    }
}

我们还忘了一个关键的函数,就是对象的构造函数。构造函数是个特殊的类成员函数,用于创建对象本身的实例。在构造函数中,我们指定一个对象如何创建。它的函数名总是和类名一样,并通过new操作符调用:

Mover m = new Mover();

在构造函数中,我们用随机的位置和速度初始化这个Mover对象:

Mover() {
    location = new PVector(random(width),random(height));
    velocity = new PVector(random(-2,2),random(-2,2));
}

如果你不熟悉面向对象编程,上面的代码可能会让你感到困惑。我们在本章的开头讨论了PVector类,位置对象和速度对象就是PVector类的实例对象。但在这里,这两个对象又变成了Mover对象的内部成员,这是怎么回事?实际上,这很容易理解。一个对象只是数据(和功能)的载体。对象内部的数据可以是数值(整型、浮点型,等等),也可以是其他对象!在后面我们会看到更多这样的用法。比如在4.1节我们会写一个类,用于描述粒子系统。粒子系统对象会包含很多粒子对象,而粒子对象内部也有很多PVector对象。

最后我们再添加一个函数,用于定义对象达到屏幕边缘时的行为。我们可以简单地实现它,让它环绕边缘运动。

void checkEdges() {

    if (location.x > width) {      一旦达到边缘,就把它的位置设置到另一边
        location.x = 0;
    } 
    else if (location.x < 0)="" {="" location.x="width;" }="" if="" (location.y=""> height) {
        location.y = 0;
    } 
    else if (location.y < 0)="" {="" location.y="height;" }="" }="">

既然我们已经完成了Mover类,下面就要开始在主程序中使用它了。首先,我们声明一个Mover对象:

Mover mover;

然后,在setup()函数中初始化这个对象:

mover = new Mover();

最后,在draw()函数中调用它的成员函数:

mover.update();
mover.checkEdges();
mover.display();  

以下是整个程序的运行效果和代码:

图像说明文字

示例代码1-7 运动101(速度)

Mover mover;     声明Mover对象

void setup() {
    size(800,200);

    mover = new Mover();       创建Mover对象
}

void draw() {
    background(255);

    mover.update();       调用Mover对象的成员函数
    mover.checkEdges();
    mover.display(); 
}

class Mover {

    PVector location;        对象有两个PVector变量:位置和速度
    PVector velocity;

    Mover() {
        location = new PVector(random(width), random(height));
        velocity = new PVector(random(-2, 2), random(-2, 2));
    }

    void update() {
        location.add(velocity);     运动101:当前位置加上速度得到一个新的位置
    }

    void display() {
        stroke(0);
        fill(175);
        ellipse(location.x, location.y, 16, 16);
    }

    void checkEdges() {
        if (location.x > width) {
            location.x = 0;
        } 
        else if (location.x < 0)="" {="" location.x="width;" }="" if="" (location.y=""> height) {
            location.y = 0;
        } 
        else if (location.y < 0)="" {="" location.y="height;" }="" }="" }="">

1.8 向量的运动:加速度

到目前为止,我们已经搞懂了两个关键的知识点:(1)PVector类;(2)如何在对象内部用PVector跟踪位置和运动。这是一个很好的开头,但在庆贺之前,我们想再向前走一步。毕竟,从运行效果上看,上面的模拟程序略显单调——屏幕中的圆从来不会加速,不会减速,也不会改变运动方向。为了让它的运行效果更有趣,更接近现实生活中的运动,我们需要在Mover类中加入一个新的PVector对象——acceleration(加速度)。

我们所指的加速度的严格定义是:速度的变化率。这并不是一个新的概念。速度被定义为位置的变化率。从本质上说,这是一种“涓滴”效应。加速度影响速度,继而影响位置(这里只是铺垫,下一章的情况会更加复杂,我们会看到力如何影响加速度,继而影响速度,最后影响位置)。用代码表示就是:

velocity.add(acceleration);
location.add(velocity);

作为练习,从现在开始,我们要为自己制定一个准则。在本书后续的例子中,我们最好不需要直接接触速度和位置(除了初始化它们)。换句话说,本章的运动模拟要达到这样的效果:我们只需要设计某种加速度的算法,最后就能让基础类完成速度和位置的计算。(实际上,你会找到打破这个准则的理由,但这个准则在运动模拟中确实非常重要。)现在,让我们制定几种获取加速度的算法。

加速度算法

1.常量加速度

2.完全随机的加速度

3.朝着鼠标所在方向的加速度

算法1用的是一个常量加速度,这不是很有趣,却是最简单的算法,我们可以通过它学习如何在代码中实现加速度。首先,我们需要在Mover类中添加一个新的PVector对象:

class Mover {

  PVector location;
  PVector velocity;

  PVector acceleration;     新的加速度向量

update()函数中加入加速度:

void update() {
  velocity.add(acceleration);     运动算法现在只有两行代码
  location.add(velocity);
}

到这里,我们差不多已经完成了。唯一遗漏的就是构造函数中的初始化代码:

Mover(){

我们想在一开始把Mover对象放在屏幕的正中间:

location = new PVector(width/2,height/2);

初始速度为0:

velocity = new PVector(0,0);

这意味着当Sketch开始运行时,这个对象是静止不动的。我们也不需要关心物体的速度,因为接下来物体的运动将完全由加速度控制。根据算法1,程序需要一个常量加速度,因此我们选择一个值作为加速度:

  acceleration = new PVector(-0.001,0.01);
}

也许你会觉得这个加速度太小了。这个值确实很小,但别忘了加速度(以像素为单位)对速度有累加的影响效应,根据Sketch的动画帧速,每秒钟速度的增量是加速度的30倍。所以为了把速度向量的大小控制在一定范围内,加速度必须非常小。我们还可以用PVector的limit()函数限制速度的大小:

velocity.limit(10);    limit()函数限制了向量的长度

这段代码的运行逻辑是这样的:

当前的速度有多大?如果小于10,保持这个速度;如果大于10,就把它减小到10。

练习 1.4

PVector类实现limit()函数:

void limit(float max) {
    if (_______ > _______) {
        _________();
        ____(max); 
    }
}

让我们来看看加入加速度和速度限制后,Mover类做了哪些改变。

图像说明文字

示例代码1-8 运动101(速度和恒定的加速度)

class Mover {

    PVector location;
    PVector velocity;        

    PVector acceleration;      加速度是关键 

    float topspeed;      topspeed变量限制了速度的大小

    Mover() {
        location = new PVector(width/2, height/2);
        velocity = new PVector(0, 0);
        acceleration = new PVector(-0.001, 0.01);
        topspeed = 10;
    }

    void update() {
        velocity.add(acceleration);       速度受加速度影响,并且受topspeed变量限制
        velocity.limit(topspeed);
        location.add(velocity);
    }

    void display() {       display()函数和之前一样
    }

    void checkEdges() {      checkEdges()函数和之前一样
    }
}

练习 1.5

模拟一辆可控制的汽车:按向上键时,汽车加速;按向下键时,汽车减速。

下面来看看算法2,该算法中的加速度是随机确定的,因此,我们不能只在构造函数中初始化加速度值,而应该在每一轮循环中选择一个随机数作为新的加速度。我们可以在update()函数中完成这项任务。

图像说明文字

示例代码1-9 运动101(速度和随机加速度)

void update() {

    acceleration = PVector.random2D();  random2D()函数返回一个长度为1、方向随机的向量 

velocity.add(acceleration);
    velocity.limit(topspeed);
    location.add(velocity);
}

由于每次调用PVector.random2D()得到的向量都是单位向量,因此还应该改变它的大小,如下。

(a)将加速度乘以一个常量:

acceleration = PVector.random2D();
acceleration.mult(0.5);   常量

(b)将加速度乘以一个随机值:

acceleration = PVector.random2D();
acceleration.mult(random(2));   随机

我们必须清楚地意识到这样一个关键点:加速度不仅仅会改变运动物体速度的大小,还会改变速度的方向。加速度用于操纵物体,在后面的章节中,我们将继续在屏幕上操纵物体运动,也会经常看到加速度的作用。

练习 1.6

参考I.6节的内容,用Perlin噪声产生物体的加速度。

1.9 静态函数和非静态函数

在开始介绍算法3(朝着鼠标所在方向的加速度)之前,为了更好地使用向量和PVector类,我们还需要了解一项重要内容:静态函数非静态函数的区别。

先把向量放在一边,让我们看看以下代码:

float x = 0;
float y = 5;

x = x + y;

这是一个很简单的例子,x的初始值是0,加上y之后,x变成了5。对于PVector对象,我们也可以很简单地写出类似代码。

PVector v = new PVector(0,0);
PVector u = new PVector(4,5);
v.add(u);

向量v的初始值是(0,0),加上向量u之后,变成了(4,5)。很简单,对吗?

然后,再来看一个浮点计算的例子:

float x = 0;
float y = 5;

float z = x + y;

x的初始值是0,加上y之后,把相加得到的结果赋给变量z。在这个过程中,x的大小从未发生变化(y也没有)!对浮点数的运算来说,这是自然而然的结果。但是,对于PVector对象,结果却不是这样的。我们先按照之前的套路实现PVector对象的运算:

PVector v = new PVector(0,0);
PVector u = new PVector(4,5);

PVector w = v.add(u);     不要上当,这是错误的实现!!!

以上的代码看上去没什么问题,但PVector类并不是这么工作的。仔细看看add()的实现:

void add(PVector v) {
    x = x + v.x;
    y = y + v.y; 
}

这样的实现和我们的目的并不相符。首先,它没有返回一个新的PVector对象(它没有返回值);其次,在调用过程中,add()函数改变了调用对象。为了将两个PVector对象相加并返回一个新的PVector对象,我们必须用静态的add()函数。

通过类名直接调用的函数(而不是通过对象实例调用)称为静态函数。下面有两个函数调用示例,它们都涉及vu两个向量对象:

PVector.add(v,u);     静态函数:通过类名调用

v.add(u);      非静态函数:通过对象实例调用

之前你可能没碰到过静态函数的用例,因为在本章之前,你不知道如何在Processing中实现一个静态函数。PVector的静态函数允许我们对两个向量对象进行数学运算,在运算过程中不改变任何一个对象的值。静态的add()函数可以这样实现:

static PVector add(PVector v1, PVector v2) {   静态的add()函数允许我们将两个向量相加,把结果赋给另一个向量,并让原向量保持不变

    PVector v3 = new PVector(v1.x + v2.x, v1.y + v2.y);
    return v3; 
}

和非静态函数相比,静态函数有以下特点:

  • 函数被声明为static

  • 函数的返回类型不是void,而是一个PVector对象;

  • 函数中会创建一个新的PVector对象(v3),它作为v1向量和v2向量相加的结果被返回。

调用静态函数不需要引用实例对象,只需用类名直接调用:

PVector v = new PVector(0,0); 
PVector u = new PVector(4,5); 
PVector w = v.add(u);
PVector w = PVector.add(v,u);

PVector类还提供了静态版本的add()sub()mult()div()函数。

练习 1.7

用静态函数或者非静态函数实现以下伪代码:

  • PVector对象v等于(1,5);
  • PVector对象u等于v乘以2;
  • PVector对象w等于v减去u
  • w向量除以3。
    PVector v = new PVector(1,5);  
    PVector u = ________._____(__,__);  
    PVector w = ________._____(__,__);  
    ___________;
    

1.10 加速度的交互

在本章的最后,让我们学点稍微复杂和有用的东西。在算法3中,物体有朝着鼠标方向的加速度,我们将根据这一规则动态计算物体的加速度。

图1-14

我们想要根据某个规则或公式计算一个向量,都必须同时算出两部分数据:大小方向。先从方向开始,加速度的方向是物体朝向鼠标的方向,假设物体的位置是(x,y),鼠标的位置是(mouseX,mouseY)。

如图1-15所示,物体的位置向量减去鼠标的位置向量,得到向量(dx,dy)。

  •  dx = mouseX - x

  • dy = mouseY - y

图1-15

我们用PVector实现上面的逻辑。假设这是在Mover类中,可以访问对象的位置:

PVector mouse = new PVector(mouseX,mouseY);
PVector dir = PVector.sub(mouse,location);   使用静态sub()函数,得到由某个点指向另一点的向量

现在,我们得到一个由Mover指向鼠标的PVector对象。如果直接把这个向量对象作为加速度,物体会瞬间移动到鼠标所在的位置,且动画效果很不明显,所以,接下来要做的就是确定物体移向鼠标的快慢。

为了设置加速度向量的大小(无论大小是多少),我们必须先单位化方向向量。只要能将方向向量缩短到1,我们就得到了一个只代表方向的单位向量,可以将它的大小设成任意值。1与任何数相乘,都等于那个数本身。

float anything = ?????
dir.normalize();
dir.mult(anything);

我们对上面说到的几个步骤做个总结:

1.计算由物体指向目标位置(鼠标)的向量;

2.单位化该向量(将向量的大小缩短为1);

3.改变以上单位向量的长度(乘以某个合适的值);

4.将步骤3中得到的向量赋给加速度。

下面,我们在update()函数中实现这些步骤:

图像说明文字

示例代码1-10 朝着鼠标位置加速

void update() {

    PVector mouse = new PVector(mouseX,mouseY);
    PVector dir = PVector.sub(mouse,location);  第一步:计算方向  

    dir.normalize();       第二步:单位化

    dir.mult(0.5);        第三步:改变长度

    acceleration = dir;     第四步:得到加速度

    velocity.add(acceleration);
    velocity.limit(topspeed);
    location.add(velocity);
}

你可能会疑惑,为什么物体运动到目标位置后不会停下来。实际上,它并不知道自己是否应该在目标位置停下来,它只知道目标位置在哪儿,并尽快地到达目标位置。这意味着它势必会超过目标位置然后再回头,回头后又想尽快到达目标位置,如此往复运动。请你暂时先保持疑惑,在后面的章节中,我们会学习如何让物体停止在目标位置(用减速的方法)。

本例和引力的概念非常接近(物体被吸引向鼠标所在的位置)。在下一章中,我们会详细探讨引力。然而,本例子和引力还有个关键的不同点,引力的大小(加速度的大小)和距离成反比,也就是说,物体离鼠标越近,加速度越大。

练习 1.8

改造上面的例子,实现这样的特性:加速度大小可变,当物体和鼠标距离越近或越远时,加速度越大。

下面有一组物体(而不是一个)同时按照上述方式运动:

图像说明文字

示例代码1-11 一组同时朝着鼠标加速的运动物体

Mover[] movers = new Mover[20];       一组对象

void setup() {
    size(200,200);
    smooth();
    background(255);
    for (int i = 0; i < movers.length; i++) {
        movers[i] = new Mover();      实例化数组中的每个对象
    }
}

void draw() {
    background(255);

    for (int i = 0; i < movers.length; i++) {
        movers[i].update();        为数组中的所有对象调用函数
        movers[i].checkEdges();
        movers[i].display(); 
    }
}

class Mover {

    PVector location;
    PVector velocity;
    PVector acceleration;
    float topspeed;

    Mover() {
        location = new PVector(random(width),random(height));
        velocity = new PVector(0,0);
        topspeed = 4;
    }

    void update() {                        计算加速度的算法

        PVector mouse = new PVector(mouseX,mouseY);   计算指向鼠标的向量
        PVector acceleration = PVector.sub(mouse,location);

        dir.normalize();     单位化

        dir.mult(0.5);     改变长度

        acceleration = dir;   赋给加速度

        velocity.add(acceleration);    运动101!加速度改变速度,速度改变位置
        velocity.limit(topspeed);
        location.add(velocity);
    }

    void display() {       绘制Mover对象
        stroke(0);
        fill(175);
        ellipse(location.x,location.y,16,16);
    }

    void checkEdges() {    边缘处理

        if (location.x > width) {
            location.x = 0;
        } else if (location.x < 0) {
            location.x = width;
    }

        if (location.y > height) {
            location.y = 0;
        } else if (location.y < 0) {
            location.y = height;
        }
    }
}

图像说明文字

图1-16 生态系统项目

生态系统项目

引言中提到,我们会逐步按照各章内容构建一个大项目。一整个实践项目的开发会贯穿本书始终——模拟生态系统。想象一下,一群模拟生物在电子池塘中游来游去,并按照一系列规则相互影响。

第1步练习

开发一套规则,用于模拟现实世界中生物的行为,如紧张的苍蝇、游动的鱼、跳跃的兔子、滑行的蛇等。你能否仅通过加速度控制这些物体的运动?请试着根据生物的行为特征(而不是外形特征),赋予它们运动特性。

目录