让图形跑起来(上篇)

在开始看这节之前,希望你已经熟悉了基本的函数绘图方法。否则你会被两个大头函数setup 和 draw 搞晕。

既然要做运动的图形,就要知道动画究竟是怎么产生的。

上图相当吸引人,而且非常直观地揭示了动画的实现原理。

动画是魔法,是关于视觉欺骗的魔法。只是在这个信息爆炸,视频满天飞的年代,我们已经对它习以为常了。也很少有人会去惊叹,能看到动画,本身就是一件神奇的事。

在程序中制作动画,原理是相通的。我们只要考虑怎么在每页上画上不同的图形,程序就会自动去翻页,而大脑会将它脑补成动画。

下面会聊聊如何实现基本的动画运动,在此之前需要我们了解一些变量的基础知识。

变量

变量是数据的容器,它可以在程序中反复使用。

例子:

size(500, 500);
ellipse(100, 250, 50, 50);
ellipse(200, 250, 50, 50);
ellipse(300, 250, 50, 50);
ellipse(400, 250, 50, 50);

这段代码没有用到变量,在屏幕上画了四个圆。仔细观察,会发现这四个圆的宽高都是一样的。既然一样,为了减少数值的重复输入,我们其实可以定义一个符号来表示,这个符号就是变量。

使用变量后的代码:

size(500, 500);
int a = 50;
ellipse(100, 250, a, a);
ellipse(200, 250, a, a);
ellipse(300, 250, a, a);
ellipse(400, 250, a, a);

绘图的结果和原来完全一样!

定义了变量 a ,我们就可以很方便地改变数值。如果将a = 50,改成a=100。那所有圆的宽高都会统一变成100,无需再逐一修改数值了。变量真是个好发明。

变量的创建

变量使用前需要先声明,并且需要指定它的数据类型。

	int i;    
	i = 50;

第一句代码声明了一个变量 i。int 是一个专门用来声明变量的符号。声明时,会在电脑内存中开辟一个空间,相当于生成了一个“箱子”,专门用来存放整数数据。

第二句代表把 50 赋值给变量 i。执行这句以后,数据就会稳稳地存放在了 i 这个变量上了。

你也可以更懒一些,将两步并作一步,声明的同时完成赋值。

	int i = 50;

变量名的命名比较自由,但也有讲究的地方。

变量的命名规则

  • 必须是字母和数字或下划线的组合。可以用一个字符,也可以用一个单词,
  • 区分大小写,name 和 Name 代表不同的变量
  • 尽量取的能让自己一眼看懂,首字符只能以字母开头,不要用数字和特殊字符。
  • 不能用关键字,比如 int,float

以下,都是错误的声明方式

    int $a;
    int 89b;

以下是合法的声明

    int r;
    int super_24;
    int openTheDoor;

变量的类型

除了可以声明为整数数据,还可以声明为小数数据(也叫浮点数据),用关键字float。

    float b = 0.5

这里要牢记自己声明数据时,究竟用了什么类型。如果用了关键字 int ,后面赋值时就不能写出 i=0.5之类的,这样程序会出错。但反过来可以,比如 float i = 5 是正确的语法,程序仍会将它识别为小数。

有些变量是系统定义好的,无需自己声明。比如前面提过的 width,height,它会自动获取屏幕的宽和高。由于这两个参数的使用频率太高了,设计者就直接把它定义成一个默认的变量,方便我们使用。

运算符

Processing 中的常用运算符有几种:

+ 加

- 减

* 乘

/ 除

取模,得出余数

  • 加减乘除大家都不陌生,只有 % 看起来会比较古怪,它得出的数是余数。9 % 3的结果是 0 。而 9 % 5 的结果会是 4。

  • 运算符可以在数值以及变量间使用。

int a = 1;         //声明整数变量a,赋值为1
int b = 2;         //声明了整数变量b,赋值为2
int c;                //声明了整数变量c
c = a + b;         //将两变量相加,并赋值给c
print(c);          //输出变量c

运行结果:

输出的结果不会显示在窗口,而是在下方的控制台。

  • 第四行的写法看上去很奇怪。但这是计算机赋值操作的常用格式。等号的左边要写最终赋值的变量,等号右边写上运算的过程。
  • 第五行的 print 函数可以在控制台打印出变量,常常用于检测数值的输出情况。

运算规则

Processing 里面比较麻烦的一点就是在运算的时候需要搞清变量的类型。特别需要注意的就是浮点数与整数类型之间的处理。

    print(6 / 5);     //结果 1

整数与整数间运算会得出整数,6 除以 5 的实际结果是 1.2 ,但程序中的输出结果会是 1 。这比较违反直觉,程序中不会进行四舍五入的处理,而是直接舍去小数点之后的数。

    print (6.0 / 5.0) ;   //结果 1.2

浮点数与浮点数之间运算会得出浮点数,实际结果是 1.2 ,程序输出结果也会是 1.2。

    print (6 / 5.0) ;     //结果 1.2
    print (6.0 / 5) ;     //结果 1.2

最后是整数和浮点数混用,最终输出的结果会是浮点数 1.2。

  • 其实你只要记住,它这种规则的设计是为了不损失数据的精度,所以只要有一方是浮点数,结果都会是浮点数。

setup,draw函数

说了一堆铺垫的知识,终于可以玩点有意思的了。

setup和draw函数,相当于是processing里面的主函数。这两个函数非常特殊,可以控制程序的流程。稍微复杂点的程序,都要把这两个家伙写进去,它们是程序的基本框架。

格式:

void setup(){

}

void draw(){

}

用法的特殊使他们调用格式也和其他函数不一样。函数名前面要加上“void”, 代表没有”返回值”(现在无需理解,记住它就好)。函数名后再跟上小括号和大括号。

看个例子:

void setup(){
  print(1);
}

void draw(){
  print(2); 
}

当按下运行按钮,控制台上会先输出数字“1”,并且不断地输出“2”,直到你按下暂停按钮或是关闭窗口。

被 setup 函数用大括号包裹的代码,只会执行一次。而draw函数内的,则会不断地循环执行(默认每秒执行60次)。

由于这种特性,setup往往会用于初始化环境属性,如屏幕宽高,背景颜色,各种变量的赋值。而draw函数里面常常放绘图函数,以产生不断变化的图像。

平移的圆

有了draw函数的出现,我们就能开始做动画了。

Processing写动画效果的方式是“很笨拙”的。不存在某种现成的指令,比如指定某形状做曲线运动,设定好速度,移动距离,它就刷刷地生成一个动画。

我们要亲自去定义这些细节。程序需要你很明确地告诉它,每帧要画一个怎样的图形。

把下面代码敲进去(现在要开始动手敲了):

int x;
int y;

void setup(){
  size(300, 300);
  x = 0;
  y = height/2;
}

void draw(){
  background(234, 113, 107);
  noStroke();
  ellipse(x, y, 50, 50);
  x = x+1;
}

这段代码展示了一个运动的圆。

前面声明了两个变量x,y。用于储存坐标的位置。变量赋值在setup函数中进行。关键代码是draw函数中的这个:

    x = x + 1

不要用数学等式去看它,否则会很奇怪。这里的“=”号是一个赋值符号,代表把右边的数值放到左边的变量里。假设 x 为50,代码运行后。等号右边就等于 50+1,即51。最终结果会赋到变量x里,于是x的值就变更为51了。

顺着程序的流程走,draw函数每运行一次,x的数值就会增加1。于是每次绘图,圆形都会比上一帧多向右平移一个像素,图形因此就动起来了。

  • 为了代码有更好的可读性,大括号里面的每行代码前面应该留出一定的空间,并且尽量对齐。键入 TAB 或者若干空格可以进行缩进。
  • 程序中空格符与换行符不会影响程序,所以多打一个少打一个往往没有关系。

这里还有别的更简便的表示方法。原来要使一个变量circle自增 1,需写成

    circle = circle +1

挺麻烦的,如果变量名越长,要打的字就越多。所以我们的懒人前辈就想出了这个办法。

    circle++

是不是超简便?它就代表自增 1。与它相似的还有 -- ,代表自减 1 。

但如果希望自增的数量是别的,比如 2 。就要用别的表示方法

    circle += 2

这个等价于

    circle = circle + 2

与它作用相似的还有 -= , /= , *= 。

运动方向

图形往哪个方向运动,取决于你怎么变化你的坐标。如果改成 y=y+1,圆就会往下运动,如果 x,y 都同时增加1,圆就会往右下方运动。写成减号会朝相反的方向。

int x, y;   //可同时声明多个,用逗号作区分

void setup(){
  size(300, 300);
  x = 0;
  y = 0;
}

void draw(){
  background(234, 113, 107);
  noStroke();
  ellipse(x, y, 50, 50);
  x++;
  y++;
}

运动速率

还记得draw函数默认每秒运行60帧吗?按这个速率去推算,上面的圆,每秒就会向右移动60像素。

想改变图形的速率,有两个办法。一个就是增大每次 x 的变化值。

    x=x+10

这样速度就比原来提升了10倍!

还有就是改变画布的刷新频率。

    frameRate()

这个函数可以改变画布的播放频率,在setup函数内写上frameRate(10),就会将播放速度改成10帧每秒。较原来默认的60帧每秒变慢了6倍。

被忽视的背景

前面的例子,都是将background函数写在draw里的,有没有想过,假如写在setup里,会有什么不同?在平移运动的例子上做些改动。

int x, y;

void setup(){
  size(300, 300);
  background(234, 113, 107);
  x = 0;
  y = height/2;
}

void draw(){
  noStroke();
  ellipse(x, y, 50, 50);
  x += 1;
}

奇怪了。可能不是很理解问题产生的原因。那将 noStroke 函数去掉,重新加上描边效果,看看圆的运动轨迹。

原来是前面画的圆形没有被清除!由于 setup 函数只运行一次,如果将 background 写在上面,就只会填充一次背景,之后就再也不起作用了。background 函数有点像油漆桶工具,一旦使用就会覆盖当前画布的所有内容,而不是仅仅设定一个背景颜色。所以将它写在draw函数的前头,才能保证每次画新图形之前,都把上一帧的内容覆盖。这样圆就能按我们的设想跑起来了。

除了记清每个函数的作用以外,我们还得琢磨代码的位置。很多时候代码上移一行,下移一行,写在大括号内,写在大括号外,会产生截然不同的效果。代码的方向是二维的。一旦出现了bug,要往这两个维度上去调试。

  • 这种不重绘的方式,适当运用可以做出很特别的效果,复制下面代码体验一番
void setup(){
  size(400, 400);
}

void draw(){
  ellipse(width/2-mouseX, height/2-mouseX, mouseY, mouseY);
  ellipse(width/2-mouseX, height/2+mouseX, mouseY, mouseY);
  ellipse(width/2+mouseX, height/2-mouseX, mouseY, mouseY);
  ellipse(width/2+mouseX, height/2+mouseX, mouseY, mouseY); 
}

这里用到了神奇的变量 mouseX 和 mouseY ,后面再详细讲解。

抖动的圆

如果我希望圆的运动方向是不规则的怎么办?巧妙地结合random函数可以做到这点。 random是一个高频使用的函数,可以用来生成随机数。它就像神出鬼没的精灵,一旦变量和它扯上关系,你完全无法预测下一步会变成什么。

调用形式:

    random(high)

high代表随机的上限,默认下限是0。比如random(10)。就会从0到10之间,随机生成一个数值(包括0,但不包括10)。

    random(low,high)

如果设置两个参数,那就会返回这两个数之间的随机值。比如random(5,10)。就会从5到10之间,随机生成一个数值(包括5,但不包括10)。

看下面例子:

float x;
x = random(50,100);
print(x);

每次运行程序,都会在控制台上输出不同的数值。

* 注意,通过random函数生成的数值为浮点类型(小数类型),如果要赋值给整型变量,要通过函数int()进行转换,转换的过程不遵循四舍五入,而是直接将小数部分舍去。因此 int(random(5)),输出的数值只有五种可能,0,1,2,3,4。

熟悉random函数的用法之后,可以直接看下面的例子。

int x, y;

void setup(){
  size(300, 300);
  x = width/2;
  y = height/2;
}

void draw(){
  background(234, 113, 107);
  noStroke();
  x += int(random(-5, 5));
  y += int(random(-5, 5));
  ellipse(x, y, 50, 50);
}

之前坐标增加的数值都是固定的。所以只要增加一个随机值,圆就会往不确定的方向移动。而随机的范围越大,它就“抖动”得越厉害。因为帧与帧之间的数值变化是跳跃的,所以运动就不是平滑的。前一帧还是在(150,150),后一帧可能就“瞬移”到(170,170)的位置了。

游走的圆

那有没有可能产生平滑的运动?noise 函数可以做到这点。它比标准的 random 函数更有韵律,生成的随机数是连续的。

调用形式:

    noise(t)

noise 无法定义它的输出范围。程序中规定它只能生成从 0 到 1 的浮点数。并且固定的输入只能产生固定的输出。

float x = noise(5);
float y = noise(5);
print(x, y);

由于上面输入的参数都是 5,所以输出的两个结果都是相同的。那如何才能使结果产生变化呢?答案就是动态地改变输入的参数。其实可以将noise理解为一个无限长的音轨。输入的参数就好比是“当前时间”,如果我们的参数输入是连续的,输出也会是连续的。

float x, y;

void setup(){
  size(700, 100);
  x = 0;
  background(0);
}

void draw(){
  x += 1;
  y = noise(frameCount/100.0)*100; 
  noStroke();
  ellipse(x, y, 2, 2);
 }

这个例子绘制了 y 值的变化轨迹。便于我们理解 noise 函数。

  • 其中,变量frameCount会获取当前的帧数。和前面介绍的 width ,height 不一样。它不是固定不变的。而是会从0开始,不断递增,如果用最开始演示的动图去理解,就代表翻到了第几页(也相当于程序中的时间概念)。

  • frameCount是整型变量,整型变量除以一个整型变量,程序中会默认将结果处理成整数。所以为了提高结果精度,需要将 100 写成 100.0 。整型变量除以浮点数,会得出浮点数。

  • 为了使 y 轴坐标在 0 到 100 里变化,需将noise的结果乘以 100。以此控制随机值的范围。

善于思考的人会问,为什么frameCount要除以100?直接写 frameCount 不行吗?是可以的,但这里为了更好地展示 noise 函数的特性,所以放慢了“播放速率”。下面的例子就展示了不同变化速率下,输出值的变化。

float x, y1, y2, y3, y4, y5;

void setup(){
  size(700, 500);
  x = 0;
  background(0);
}

void draw(){
  
  x += 1;
  y1 = noise(frameCount)*100;
  y2 = noise(frameCount/10.0)*100;
  y3 = noise(frameCount/100.0)*100;
  y4 = noise(frameCount/1000.0)*100;
  y5 = noise(frameCount/10000.0)*100;
  noStroke();

  ellipse(x, y1, 2, 2);
  ellipse(x, y2+100, 2, 2);
  ellipse(x, y3+200, 2, 2);
  ellipse(x, y4+300, 2, 2); 
  ellipse(x, y5+400, 2, 2);  
  
  stroke(80);
  line(0,100,width,100);
  line(0,200,width,200);
  line(0,300,width,300);
  line(0,400,width,400);
 }

你可以将noise里面变化的参数理解成进度条,变化这个参数,就相当于拨动进度条。所以当这个“音轨”输入参数的变化幅度越大,输出值前后的连续性也会越弱(可以想象2倍速,5倍速,20倍速播放一段音乐或是视频会是怎样的)。当参数的变化幅度大于某个值,可能就与random函数生成的值没有太大差别了。

如果前面的例子都能理解。那画一个游走的圆,是再简单不过的事。你也会明白其中的道理。

float x, y;

void setup(){
  size(300, 300);
  x = 0;
}

void draw(){
  background(234, 113, 107);
  x = noise(frameCount/100.0 + 100)*300;
  y = noise(frameCount/100.0)*300; 
  noStroke();
  ellipse(x, y, 50, 50);
 }

现在的运动就比较有意思了,就像一个旋转摇摆的陀螺一样。

  • 其中变量 x 的 noise 里面的参数之所以要加上 100 ,是为了错开一段距离。如果xy里面 noise 函数输入的参数完全一样,又或是过于接近。x,y坐标的变化就会趋近相同。这样是为了让运动更有随机性。

用鼠标移动圆

下面终于讲到这两个自己最喜欢的两个变量了,mouseX 和 mouseY。当初在看到这两个概念的时候,就让人两眼发光。因为这是与图像发生交互最直接的方式。灵活运用它,可以写出很多好玩的程序。

例子很简单:

int x, y;

void setup(){
  size(300, 300);
  x = 0;
  y = 0;
}

void draw(){
  background(234, 113, 107);
  noStroke();
  x = mouseX;
  y = mouseY;
  ellipse(x, y, 50, 50);
}

mouseX 可以实时获取鼠标的 x 坐标,mouseY可以获取y坐标。

  • 改变下正负号,又或是交换 mouseX 和 mouseY 试试

End

通过熟悉这些命令,你已经可以简单地指挥图形的运动了。再结合上节内容,充分发挥你的想象力,就足以做出很多有意思的动效。

下一节将会看到更多丰富的实例,同时也会用上数学函数,将它和图形的运动结合起来。